Files
GiteaMailTemplates/preview/index.html
T
2026-10-11 11:05:48 +08:00

756 lines
31 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gitea Mail Templates - Live Preview</title>
<style>
:root{
--bg:#0b1120;--surface:#151d2e;--border:#1e2d45;--text:#d1d9e6;--muted:#6b7d99;
--accent:#4da6ff;--green:#34d399;--red:#f87171;--warn:#fbbf24;
--nav-h:48px;--panel-w:440px;--rad:6px
}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;flex-direction:column}
/* Top bar */
#topbar{min-height:var(--nav-h);background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;flex-wrap:wrap;padding:6px 12px;gap:8px;row-gap:4px;flex-shrink:0;z-index:30;position:relative}
#topbar .brand{font-weight:700;font-size:14px;color:#fff;white-space:nowrap;letter-spacing:-0.2px}
#topbar .brand span{color:var(--accent);font-weight:400;font-size:11px;margin-left:4px;text-transform:uppercase;letter-spacing:1px}
.ctl-group{display:inline-flex;align-items:center;gap:3px;white-space:nowrap}
.ctl-group label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px}
#topbar .sep{width:1px;height:20px;background:var(--border);flex-shrink:0;align-self:center}
#topbar select{background:var(--bg);color:var(--text);border:1px solid var(--border);padding:5px 8px;border-radius:var(--rad);font-size:12px;cursor:pointer;outline:none;min-width:80px}
#topbar select:focus{border-color:var(--accent)}
#topbar .btn-group{display:flex;flex-shrink:0}
#topbar .btn-group button{background:var(--bg);color:var(--muted);border:1px solid var(--border);padding:6px 12px;font-size:12px;font-weight:500;cursor:pointer;transition:all .15s}
#topbar .btn-group button:first-child{border-radius:var(--rad) 0 0 var(--rad)}
#topbar .btn-group button:last-child{border-radius:0 var(--rad) var(--rad) 0}
#topbar .btn-group button+button{border-left:none}
#topbar .btn-group button.on{background:var(--accent);color:#000;border-color:var(--accent);font-weight:600}
#topbar .hint{font-size:10px;color:var(--muted);opacity:.5;white-space:nowrap}
#btn-screenshot{display:inline-flex;align-items:center;gap:6px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--rad);padding:6px 12px;font-size:12px;cursor:pointer}
#btn-screenshot svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.8;flex-shrink:0}
#btn-screenshot:hover:not(:disabled){border-color:var(--accent)}
#btn-screenshot:disabled{opacity:.45;cursor:wait}
button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
/* Topbar responsive — progressively compact as width shrinks */
@media(max-width:900px){#topbar .hint{display:none}}
@media(max-width:700px){
#topbar{gap:5px;row-gap:3px;padding:4px 8px}
#topbar .sep{display:none}
#topbar select{min-width:70px;font-size:11px;padding:4px 6px}
.ctl-group label{font-size:9px}
#topbar .btn-group button{padding:4px 10px;font-size:11px}
}
@media(max-width:500px){
#topbar{gap:4px;row-gap:2px;padding:4px 6px}
#topbar .brand{font-size:12px}
#topbar .brand span{font-size:10px}
#topbar select{min-width:60px;font-size:10px;padding:3px 5px}
#topbar .btn-group button{padding:3px 8px;font-size:10px}
}
/* Main layout */
#main{flex:1;display:flex;position:relative;min-height:0;overflow:hidden}
#preview-area{flex:1;min-width:0;min-height:0;display:flex;align-items:flex-start;justify-content:center;padding:24px;overflow:auto;background:linear-gradient(135deg,#0b1120,#131c30,#0d1525)}
/* Panel and preview share the available width; the handle occupies its own gutter. */
#panel{
--panel-w:min(440px,40vw);
position:relative;flex:0 0 var(--panel-w);width:var(--panel-w);min-width:0;
margin:12px 12px 12px 40px;
background:var(--surface);
border:1px solid var(--border);
border-radius:10px;
box-shadow:-4px 4px 32px rgba(0,0,0,.5),0 0 0 1px rgba(255,255,255,.03) inset;
display:flex;flex-direction:column;
transition:flex-basis .3s ease,width .3s ease;
}
#panel.collapsed{
flex-basis:0;width:0;border-width:0;background:transparent;box-shadow:none;
}
#panel.collapsed #panel-body{visibility:hidden}
/* Panel drag-handle / collapse tab */
#panel-handle{
position:absolute;left:-40px;top:50%;transform:translateY(-50%);
width:40px;height:80px;
background:var(--surface);
border:1px solid var(--border);
border-right:none;
border-radius:6px 0 0 6px;
cursor:pointer;
display:flex;align-items:center;justify-content:center;
transition:background .15s;
color:var(--muted);
}
#panel-handle:hover{background:var(--bg)}
#panel-handle svg{
width:20px;height:20px;
stroke:var(--muted);
fill:none;stroke-width:2;
transition:transform .35s cubic-bezier(.4,0,.2,1),stroke .15s;
}
#panel-handle:hover svg{stroke:var(--text)}
#panel.collapsed #panel-handle svg{transform:rotate(180deg)}
/* Panel inner scroll */
#panel-body{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;border-radius:10px}
/* Section */
#panel section{padding:12px 14px;border-bottom:1px solid var(--border)}
#panel section:last-child{border-bottom:none}
/* Collapsible section header */
.section-hdr{
display:flex;align-items:center;gap:6px;
cursor:pointer;
user-select:none;
padding:2px 0;
border-radius:4px;
transition:color .15s;
}
.section-hdr:hover{color:#fff}
.section-hdr h3{
font-size:10px;font-weight:600;color:inherit;text-transform:uppercase;letter-spacing:.8px;
flex:1;
}
.section-chevron{
width:12px;height:12px;
stroke:currentColor;fill:none;stroke-width:2;
transition:transform .25s ease;
flex-shrink:0;
opacity:.5;
}
.section-hdr:hover .section-chevron{opacity:1}
section.collapsed .section-chevron{transform:rotate(-90deg)}
/* Collapsible content wrapper */
.section-content{
margin-top:10px;
}
section.collapsed .section-content{
display:none;
}
#sec-params .section-content{max-height:50vh;overflow:auto;overscroll-behavior:contain}
#panel .card{background:var(--bg);border-radius:var(--rad);padding:10px 12px;margin-bottom:8px}
#panel .card:last-child{margin-bottom:0}
#panel .card .label{font-size:10px;color:var(--muted);margin-bottom:2px}
#panel .card .value{font-size:12px;color:var(--text)}
#panel table{width:100%;border-collapse:collapse}
#panel table td{padding:4px 6px;font-size:11px;border-bottom:1px solid rgba(255,255,255,.03);vertical-align:top}
#panel table td:first-child{font-family:monospace;color:var(--accent);font-size:10px;overflow-wrap:anywhere}
#panel table td:nth-child(2){color:var(--muted);font-size:10px;overflow-wrap:anywhere}
#panel table td:last-child{text-align:right;width:1%}
#panel .badge{display:inline-block;font-size:9px;padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:.2px;white-space:nowrap}
.badge-req{background:rgba(52,211,153,.12);color:var(--green)}
.badge-opt{background:rgba(107,125,153,.1);color:var(--muted)}
#panel .stat{font-size:10px;color:var(--muted);margin-top:6px}
/* Viewport */
#viewport{border-radius:var(--rad);box-shadow:0 8px 32px rgba(0,0,0,.45);flex-shrink:0;max-width:100%;overflow:hidden;background:#fff}
#viewport.desktop{width:100%;max-width:1386px;height:780px}
#viewport.mobile{width:390px;height:780px;border-radius:28px;border:3px solid #1e293b;box-shadow:0 8px 32px rgba(0,0,0,.5),inset 0 0 0 1px #0f172a;display:flex;flex-direction:column}
#viewport.mobile #preview-frame{flex:1;min-height:0}
#viewport.mobile::before{content:'';display:block;height:24px;background:#1e293b;flex-shrink:0}
#preview-frame{width:100%;height:100%;border:none;display:block;background:#fff}
#viewport.desktop #preview-frame{border-radius:var(--rad)}
/* Loading */
.loading-wrap{display:flex;align-items:center;justify-content:center;height:100%;color:var(--muted);font-size:13px;flex-direction:column;gap:8px}
.spinner{width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
#messages{flex-shrink:0}
.message{display:block;padding:6px 16px;font-size:11px;text-align:center;border-bottom:1px solid transparent}
.message-warn{background:rgba(251,191,36,0.08);color:rgba(251,191,36,0.75);border-color:rgba(251,191,36,0.15)}
.message-error{background:rgba(248,113,113,0.08);color:rgba(248,113,113,0.75);border-color:rgba(248,113,113,0.15)}
.message-info{background:rgba(77,166,255,0.08);color:rgba(77,166,255,0.8);border-color:rgba(77,166,255,0.15)}
.message code{background:rgba(0,0,0,0.12);padding:1px 6px;border-radius:3px}
/* Scrollbar — neutral gray, consistent across all browsers */
html,body,#preview-area,#panel-body,#sec-params .section-content{
scrollbar-width:thin;
scrollbar-color:rgba(107,125,153,.25) transparent;
}
#preview-area,#panel-body{
-ms-overflow-style:-ms-autohiding-scrollbar;
}
#preview-area::-webkit-scrollbar,
#sec-params .section-content::-webkit-scrollbar,
#panel-body::-webkit-scrollbar { width:6px; height:6px }
#preview-area::-webkit-scrollbar-track,
#sec-params .section-content::-webkit-scrollbar-track,
#panel-body::-webkit-scrollbar-track { background:transparent }
#preview-area::-webkit-scrollbar-thumb,
#sec-params .section-content::-webkit-scrollbar-thumb,
#panel-body::-webkit-scrollbar-thumb {
background-color:rgba(107,125,153,.25);
border-radius:3px;
border:1px solid rgba(255,255,255,.06);
}
#preview-area::-webkit-scrollbar-thumb:hover,
#sec-params .section-content::-webkit-scrollbar-thumb:hover,
#panel-body::-webkit-scrollbar-thumb:hover { background-color:rgba(107,125,153,.45) }
#preview-area::-webkit-scrollbar-corner,
#sec-params .section-content::-webkit-scrollbar-corner,
#panel-body::-webkit-scrollbar-corner { background:transparent }
@media(max-width:700px){
#panel{--panel-w:50vw;margin-top:6px;margin-bottom:6px;margin-right:6px}
#preview-area{padding:12px}
}
@media(prefers-reduced-motion:reduce){#panel,#panel-handle svg{transition:none}}
</style>
</head>
<body>
<nav id="topbar">
<span class="brand">Gitea Mail Templates <span>Preview</span></span>
<span class="sep"></span>
<span class="ctl-group">
<label>Theme</label>
<select id="sel-theme" onchange="switchTheme(this.value)"></select>
</span>
<span class="sep"></span>
<span class="ctl-group">
<label>Template</label>
<select id="sel-template" onchange="switchTemplate(this.value)"></select>
</span>
<span class="sep"></span>
<span class="ctl-group">
<label>Language</label>
<select id="sel-locale" onchange="switchLocale(this.value)"></select>
</span>
<span class="sep"></span>
<span class="ctl-group">
<label>View</label>
<select id="sel-client" onchange="switchClient(this.value)">
<option value="modern">Modern</option>
<option value="source">Source</option>
</select>
</span>
<span class="sep"></span>
<div class="btn-group">
<button id="btn-desk" class="on" onclick="setViewport('desktop')">Desktop</button>
<button id="btn-mob" onclick="setViewport('mobile')">Mobile</button>
</div>
<button id="btn-screenshot" type="button" onclick="capturePreview()" disabled>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5l1.5-2h5L16 5h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z"/><circle cx="12" cy="13" r="4"/></svg>
<span id="screenshot-label">Screenshot</span>
</button>
<span class="hint">keys: d/m &nbsp; &larr;&rarr; tab &nbsp; &uarr;&darr; select &nbsp; p toggle panel</span>
</nav>
<div id="messages"></div>
<div id="main">
<div id="preview-area">
<div id="viewport" class="desktop">
<div class="loading-wrap" id="loading">
<div class="spinner"></div>
<span>Loading pre-rendered templates...</span>
</div>
<iframe id="preview-frame" style="display:none"></iframe>
</div>
</div>
<aside id="panel">
<!-- Collapse handle tab on the left edge -->
<button id="panel-handle" type="button" title="Collapse panel" aria-label="Collapse panel" aria-expanded="true" aria-controls="panel-body" onclick="togglePanel()">
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
</button>
<div id="panel-body">
<section id="sec-info">
<div class="section-hdr" onclick="toggleSection('sec-info')">
<h3>Current Template</h3>
<svg class="section-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="section-content">
<div class="card">
<div class="label">Type</div>
<div class="value" id="panel-name">-</div>
</div>
<div class="card">
<div class="label">Description</div>
<div class="value" id="panel-desc" style="font-size:11px;line-height:1.5">-</div>
</div>
<div class="stat" id="panel-stats"></div>
</div>
</section>
<section id="sec-params">
<div class="section-hdr" onclick="toggleSection('sec-params')">
<h3>Parameters</h3>
<svg class="section-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="section-content">
<table id="panel-params"><tr><td colspan="2" style="color:var(--muted)">-</td></tr></table>
</div>
</section>
<section id="sec-source">
<div class="section-hdr" onclick="toggleSection('sec-source')">
<h3>Source</h3>
<svg class="section-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="section-content">
<div class="card" style="font-family:monospace;font-size:10px;color:var(--muted);word-break:break-all" id="panel-path">-</div>
</div>
</section>
</div>
</aside>
</div>
<script src="lib/html2canvas/html2canvas.min.js"></script>
<script src="rendered.js"></script>
<script>
//
// State — REGISTRY and PARAMS are loaded from rendered.js (single source of truth)
//
const state = { theme:'', template:'', client:'modern', viewport:'desktop', locale:'en-US' };
let REGISTRY = {};
let PARAMS = {};
let rendered = null;
let manifest = null;
let localeRequest = 0;
let renderRequest = 0, renderTimer;
const localeLoads = new Map();
let devMode = false; // true once SSE connects
let previewReady = false;
let capturing = false;
function updateCaptureButton() {
document.getElementById('btn-screenshot').disabled = !previewReady || capturing;
}
// Capture only the visible viewport, including the mobile frame when selected.
async function capturePreview() {
if (!previewReady || capturing) return;
const label = document.getElementById('screenshot-label');
const selection = { ...state };
const request = renderRequest;
const frame = document.getElementById('preview-frame');
const viewport = document.getElementById('viewport');
capturing = true;
label.textContent = 'Capturing…';
updateCaptureButton();
document.getElementById('capture-message')?.remove();
try {
if (typeof html2canvas !== 'function') throw new Error('Screenshot library unavailable; restore preview/lib/html2canvas.');
const doc = frame.contentDocument;
if (!doc) throw new Error('Preview document is not accessible.');
if (doc.fonts) await doc.fonts.ready;
await Promise.all(Array.from(doc.images, image => image.decode().catch(() => {})));
if (request !== renderRequest || !previewReady) throw new Error('Preview changed; capture again after it finishes loading.');
const width = viewport.offsetWidth;
const height = viewport.offsetHeight;
const frameWidth = frame.clientWidth;
const frameHeight = frame.clientHeight;
const scrollX = frame.contentWindow.scrollX;
const scrollY = frame.contentWindow.scrollY;
const viewportBounds = viewport.getBoundingClientRect();
const frameBounds = frame.getBoundingClientRect();
const frameLeft = frameBounds.left - viewportBounds.left;
const frameTop = frameBounds.top - viewportBounds.top;
const scale = Math.min(window.devicePixelRatio || 1, 2);
// Render in the mail document's own coordinates. Rendering nested iframes
// with the outer document's context can shift centered tables and text.
const mailCanvas = await html2canvas(doc.documentElement, {
backgroundColor: '#fff',
scale,
width: frameWidth,
height: frameHeight,
windowWidth: frameWidth,
windowHeight: frameHeight,
x: scrollX,
y: scrollY,
scrollX,
scrollY,
useCORS: true,
logging: false
});
const canvas = await html2canvas(viewport, {
backgroundColor: null,
scale,
width,
height,
useCORS: true,
logging: false,
ignoreElements: element => element.id === 'preview-frame',
onclone: clone => {
const clonedViewport = clone.getElementById('viewport');
clonedViewport.style.width = width + 'px';
clonedViewport.style.maxWidth = 'none';
clonedViewport.style.height = height + 'px';
}
});
if (request !== renderRequest || !previewReady) throw new Error('Preview changed; capture again after it finishes loading.');
const context = canvas.getContext('2d');
if (!context) throw new Error('Could not create the screenshot canvas.');
const style = getComputedStyle(viewport);
const border = parseFloat(style.borderLeftWidth) || 0;
const radius = Math.max(0, (parseFloat(style.borderTopLeftRadius) || 0) - border);
// Clip the composite to the container's inner rounded border, including
// the mobile frame. No top bar or sidebar enters the exported image.
const left = border, top = border;
const right = width - border, bottom = height - border;
const rounding = Math.min(radius, (right - left) / 2, (bottom - top) / 2);
context.save();
context.scale(scale, scale);
context.beginPath();
context.moveTo(left + rounding, top);
context.arcTo(right, top, right, bottom, rounding);
context.arcTo(right, bottom, left, bottom, rounding);
context.arcTo(left, bottom, left, top, rounding);
context.arcTo(left, top, right, top, rounding);
context.closePath();
context.clip();
context.drawImage(mailCanvas, frameLeft, frameTop, frameWidth, frameHeight);
context.restore();
const blob = await new Promise((resolve, reject) => canvas.toBlob(
value => value ? resolve(value) : reject(new Error('Could not encode the screenshot.')),
'image/png'
));
const filename = [selection.theme, selection.template, selection.locale]
.map(value => value.replace(/[<>:"/\\|?*\x00-\x1f]/g, '-')).join('_') + '.png';
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
// Give the browser time to start reading the download before releasing it.
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch (error) {
const message = showMessage('', 'error');
message.id = 'capture-message';
message.textContent = 'Screenshot failed: ' + String(error.message || error);
} finally {
capturing = false;
label.textContent = 'Screenshot';
updateCaptureButton();
}
}
//
// Client transforms
//
function transform(html, mode) {
// Source view — escape HTML for raw display
if(mode==='source') {
const e = html.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return '<!DOCTYPE html><html><head><style>body{background:#0d1117;color:#c9d1d9;font-family:Consolas,monospace;padding:16px;margin:0}pre{white-space:pre-wrap;word-break:break-all;font-size:11px;line-height:1.6;margin:0}</style></head><body><pre>'+e+'</pre></body></html>';
}
// Gitea sends status icons as CID attachments. Preview has no MIME message,
// so display equivalent local SVG icons without changing install templates.
const doc = new DOMParser().parseFromString(html, 'text/html');
// Static preview has no deployed instance; use the generated official asset.
// Installable mail templates keep the instance's {{AppUrl}} image URL.
doc.querySelectorAll('img.email-logo').forEach(img => { if(manifest?.logo) img.src=manifest.logo; });
doc.querySelectorAll('img[src^="cid:"]').forEach(img => {
const color = img.classList.contains('status-success') ? '#2da44e' : '#697077';
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><circle cx="9" cy="9" r="7" fill="'+color+'"/><path d="M5 9l3 3 5-6" fill="none" stroke="white" stroke-width="2"/></svg>';
img.src = 'data:image/svg+xml,' + encodeURIComponent(svg);
});
return '<!DOCTYPE html>' + doc.documentElement.outerHTML;
}
//
// Render preview
//
function render() {
const request=++renderRequest;
clearTimeout(renderTimer);
const frame = document.getElementById('preview-frame');
const loading = document.getElementById('loading');
if(!rendered) return;
// Same-origin DOM access is required for capture; scripts remain disabled.
frame.setAttribute('sandbox', 'allow-same-origin');
previewReady = false;
updateCaptureButton();
frame.style.display = 'none';
loading.style.display = 'flex';
const src = rendered;
const raw = src[state.theme]?.[state.template];
let content = raw ? transform(raw, state.client) : '<p style=\"color:red;padding:20px\">Not found: '+state.theme+'/'+state.template+'</p>';
content = content.replace('</head>', '<style>html,body{scrollbar-width:none;-ms-overflow-style:none}html::-webkit-scrollbar,body::-webkit-scrollbar{display:none;width:0;height:0}</style></head>');
let done = false;
function finish() {
if (done || request!==renderRequest) return; done = true;
clearTimeout(renderTimer);
loading.style.display = 'none';
frame.style.display = 'block';
frame.onload = null;
previewReady = true;
updateCaptureButton();
}
frame.onload = finish;
frame.srcdoc = content;
renderTimer=setTimeout(finish, 5000);
updatePanel();
}
//
// Panel — collapse/expand
//
function togglePanel() {
const panel = document.getElementById('panel');
const collapsed = panel.classList.toggle('collapsed');
const handle = document.getElementById('panel-handle');
handle.setAttribute('aria-expanded', String(!collapsed));
handle.setAttribute('aria-label', collapsed ? 'Expand panel' : 'Collapse panel');
handle.title = collapsed ? 'Expand panel' : 'Collapse panel';
document.getElementById('panel-body').inert = collapsed;
}
function toggleSection(id) {
const sec = document.getElementById(id);
sec.classList.toggle('collapsed');
}
//
// Panel content updates
//
function updatePanel() {
const escape = v => String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const r = REGISTRY[state.template];
document.getElementById('panel-name').textContent = r ? r.name : state.template;
document.getElementById('panel-desc').textContent = r ? r.desc : '';
document.getElementById('panel-path').textContent = r ? 'build/themes/'+state.theme+'/'+r.path+' (official snapshot + theme overrides)' : '-';
// Template stats
const themes = rendered ? Object.keys(rendered).length : 0;
let totalTemplates = 0, totalFailed = 0;
if (rendered && rendered[state.theme]) {
const themeData = rendered[state.theme];
totalTemplates = Object.keys(themeData).length;
for (const v of Object.values(themeData)) {
if (v.startsWith('<p style=\'color:red\'')) totalFailed++;
}
}
document.getElementById('panel-stats').textContent =
manifest.upstream + ' · ' + state.locale + ' · ' + themes + ' themes · ' + totalTemplates + ' templates (' + (totalTemplates - totalFailed) + ' ok, ' + totalFailed + ' failed)';
const p = PARAMS[state.template] || {};
let rows = '';
for(const [k,v] of Object.entries(p)){
const isReq = !k.includes('RenderedNote') && !k.includes('Attachments') && !k.includes('DisableDownload') && !k.includes('IsMention') && !k.includes('IsPull') && !k.includes('IsForcePush') && !k.includes('ReviewComments') && !k.includes('Commits') && !k.includes('BaseBranch');
rows += '<tr><td>.'+escape(k)+'</td><td>'+escape(v)+'</td><td><span class="badge '+(isReq?'badge-req':'badge-opt')+'">'+(isReq?'required':'optional')+'</span></td></tr>';
}
document.getElementById('panel-params').innerHTML = rows || '<tr><td colspan="3" style="color:var(--muted)">-</td></tr>';
}
//
// Events
//
function switchTheme(v) { state.theme=v; render(); }
function switchTemplate(v) { state.template=v; switchLocale(state.locale); }
function switchClient(v) { state.client=v; render(); }
function setViewport(v) {
state.viewport=v;
document.getElementById('btn-desk').classList.toggle('on',v==='desktop');
document.getElementById('btn-mob').classList.toggle('on',v==='mobile');
document.getElementById('viewport').className=v;
render();
}
//
// Keyboard
//
document.addEventListener('keydown',e=>{
// Viewport shortcuts (no modifiers)
if(e.key==='d'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){ setViewport('desktop'); return; }
if(e.key==='m'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){ setViewport('mobile'); return; }
// 'p' to toggle the side panel
if(e.key==='p'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){ togglePanel(); return; }
// ← → cycle focus between Theme / Template / Client selects
const selects=['sel-theme','sel-template','sel-locale','sel-client'];
const activeId=document.activeElement?.id;
const cur=selects.indexOf(activeId);
if(e.key==='ArrowRight'||e.key==='ArrowLeft'){
e.preventDefault();
if(cur>=0){
const dir=e.key==='ArrowRight'?1:-1;
const next=(cur+dir+selects.length)%selects.length;
document.getElementById(selects[next])?.focus();
} else {
document.getElementById('sel-template')?.focus();
}
}
});
//
// Manifest and language bundles
function fillSelect(id, entries, selected) {
const select = document.getElementById(id);
select.replaceChildren();
for (const [value,label] of entries) {
const option = document.createElement('option');
option.value=value; option.textContent=label; select.appendChild(option);
}
select.value=selected;
}
function applyManifest(data) {
if (!data || data.schema !== 1 || !data.languages.length || !data.themes.length) {
throw new Error('Invalid preview manifest; regenerate with go run . preview all');
}
manifest=data; REGISTRY=data.registry; PARAMS=data.params;
if (!data.themes.includes(state.theme)) state.theme=data.themes[0];
if (!REGISTRY[state.template]) state.template=Object.keys(REGISTRY)[0];
if (!data.languages.includes(state.locale)) state.locale=data.languages.includes('en-US') ? 'en-US' : data.languages[0];
fillSelect('sel-theme',data.themes.map(t=>[t,t.charAt(0).toUpperCase()+t.slice(1)]),state.theme);
fillSelect('sel-template',Object.entries(REGISTRY).map(([id,t])=>[id,t.name]),state.template);
fillSelect('sel-locale',data.languages.map(locale=>{
let name;
try {
// Show each language in its own language, independent of the browser UI.
if (Intl.DisplayNames.supportedLocalesOf([locale]).length) {
name=new Intl.DisplayNames([locale],{type:'language',fallback:'none'}).of(locale);
}
} catch (_) { /* Older browsers can still select languages by locale code. */ }
return [locale,name && name!==locale ? locale+' — '+name : locale];
}),state.locale);
document.querySelectorAll('#sel-locale option').forEach(option=>{
option.lang=option.value;
option.dir='auto';
});
}
function loadLocale(lang) {
if (window.__MAIL_PREVIEW_DATA__?.[lang]) return Promise.resolve(window.__MAIL_PREVIEW_DATA__[lang]);
if (localeLoads.has(lang)) return localeLoads.get(lang);
const promise=new Promise((resolve,reject)=>{
const script=document.createElement('script');
script.src='rendered/'+encodeURIComponent(lang)+'.js'+(devMode ? '?v='+Date.now() : '');
script.onload=()=>{
script.remove();
const data=window.__MAIL_PREVIEW_DATA__?.[lang];
if (data) resolve(data); else reject(new Error('Invalid language bundle '+lang));
};
script.onerror=()=>{ script.remove(); reject(new Error('Language bundle missing: '+lang+'; regenerate preview data')); };
document.head.appendChild(script);
});
localeLoads.set(lang,promise);
promise.catch(()=>localeLoads.delete(lang));
return promise;
}
async function switchLocale(lang) {
if (!manifest?.languages.includes(lang)) return;
state.locale=lang; rendered=null;
previewReady=false;
updateCaptureButton();
const request=++localeRequest;
document.getElementById('preview-frame').style.display='none';
document.getElementById('loading').style.display='flex';
try {
const data=await loadLocale(lang);
if (request!==localeRequest) return;
rendered=data;
try { localStorage.setItem('mail-preview-locale',lang); } catch (_) {}
document.getElementById('locale-message')?.remove();
const warnings=manifest.warnings?.[lang]?.[state.template] || [];
const fallback=manifest.fallback?.[lang] || [];
if (fallback.length || warnings.length) {
const el=showMessage('',warnings.length ? 'warn' : 'info'); el.id='locale-message';
el.textContent=warnings.join(' ') || '[FALLBACK] '+lang+': '+fallback.length+' referenced translation keys use en-US';
}
render();
} catch (error) {
if (request!==localeRequest) return;
document.getElementById('loading').style.display='none';
showMessage('', 'error').textContent = String(error);
}
}
(function(){
try {
try { state.locale=localStorage.getItem('mail-preview-locale') || 'en-US'; } catch (_) {}
applyManifest(window.__MAIL_PREVIEW__);
switchLocale(state.locale);
} catch (error) {
document.getElementById('loading').style.display='none';
showMessage('Preview data unavailable — run <code>cd tools &amp;&amp; go run . preview all</code> to generate','error');
}
})();
// Message helpers
function showMessage(ht, type) {
const container = document.getElementById('messages');
const el = document.createElement('div');
el.className = 'message message-' + (type || 'warn');
el.innerHTML = ht;
container.appendChild(el);
return el;
}
function clearMessages() {
document.getElementById('messages').innerHTML = '';
}
function setDevMode(on) {
devMode = on;
if (on) {
clearMessages();
showMessage(
'Live reload active — theme CSS, shared controls, lock/cache and fixtures auto-refresh the preview.',
'info'
).id = 'msg-dev-disclaimer';
}
}
// Dev-server live-reload (SSE)
(function(){
if (location.protocol === 'file:') return;
let es;
try {
es = new EventSource('/events');
} catch(e) {
return;
}
es.onmessage = function(e) {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'reload') {
const script = document.createElement('script');
script.src = 'rendered.js?v=' + Date.now();
script.onload = function() {
script.remove();
window.__MAIL_PREVIEW_DATA__ = {};
localeLoads.clear();
applyManifest(window.__MAIL_PREVIEW__);
switchLocale(state.locale);
};
document.head.appendChild(script);
} else if (msg.type === 'error') {
showMessage('', 'error').textContent = msg.message;
}
} catch(_) {}
};
es.onopen = function() {
setDevMode(true);
console.log('[dev] live-reload connected');
};
es.onerror = function() {
if (devMode) {
showMessage('Dev server disconnected — reload the page to reconnect', 'error');
setDevMode(false);
}
console.log('[dev] live-reload disconnected');
};
})();
</script>
</body>
</html>