Files
GiteaMailTemplates/preview/index.html
T
KenanZhu d8a714b2b5 feat(preview): cross-browser scrollbar, keyboard nav, dev-mode client simulation, docs overhaul
preview/index.html:
- Fix scrollbar CSS: scoped webkit pseudo-elements + standards-track fallback, removed deprecated overflow:overlay
- Keyboard navigation: arrows cycle focus between Theme/Template/Client selects, up/down select within focused dropdown, d/m toggle viewport
- Dev/static mode detection: delayed static warning only when WebSocket absent, WebSocket errors only on disconnect (not on initial fail)
- iframe sandbox: removed static sandbox attr, applied dynamically only in dev mode (file: protocol rejects sandboxed srcdoc)
- Loading fix: removed double-toggle between init and render(); added 5s safety timeout
- Transform clean: returns HTML as-is; CSS stripping moved to server-side
- Dev disclaimer: blue info banner shown on WebSocket connect

tools/server/inliner.mjs:
- Added stripGmail() / stripOutlook() — server-side CSS property stripping for email client simulation

tools/server/server.mjs:
- Fixed WebSocket upgrade: use app.listen() instead of createServer(app).listen()
- Juice post-processing now generates three rendered.js variants (modern/gmail/outlook)
- Initial startup runs juice-only pass (avoids duplicate Go compilation)

docs/ (all 6 languages — en, zh-CN, zh-TW, ja, ko, ru):
- Image size limits: max 50KiB, recommended 10-20KiB
- Dev disclaimer: simulation cannot 100% reproduce every client
- Changed 'accurate' to 'relatively accurate' across all static-mode warnings
- Converted all plain blockquotes to GitHub admonitions ([!WARNING] / [!NOTE])
2026-06-04 16:01:42 +08:00

476 lines
21 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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:540px;--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{height:var(--nav-h);background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 16px;gap:14px;flex-shrink:0;z-index:10}
#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}
#topbar .sep{width:1px;height:20px;background:var(--border)}
#topbar label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin-right:-8px}
#topbar select{background:var(--bg);color:var(--text);border:1px solid var(--border);padding:6px 10px;border-radius:var(--rad);font-size:12px;cursor:pointer;outline:none;min-width:90px}
#topbar select:focus{border-color:var(--accent)}
#topbar .btn-group{display:flex}
#topbar .btn-group button{background:var(--bg);color:var(--muted);border:1px solid var(--border);padding:6px 14px;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}
/* Main layout */
#main{flex:1;display:flex;min-height:0}
#preview-area{flex:1;display:flex;align-items:flex-start;justify-content:center;padding:24px;overflow:auto;background:linear-gradient(135deg,#0b1120,#131c30,#0d1525)}
/* Viewport */
#viewport{border-radius:var(--rad);box-shadow:0 8px 32px rgba(0,0,0,.45);transition:all .3s;flex-shrink:0;overflow:hidden;background:#fff}
#viewport.desktop{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::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)}
/* Side panel */
#panel{width:var(--panel-w);background:var(--surface);border-left:1px solid var(--border);overflow-y:auto;flex-shrink:0;display:flex;flex-direction:column}
#panel section{padding:14px 16px;border-bottom:1px solid var(--border)}
#panel section:last-child{border-bottom:none}
#panel h3{font-size:10px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;margin-bottom:10px}
#panel .card{background:var(--bg);border-radius:var(--rad);padding:10px 12px;margin-bottom:8px}
#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;width:0;white-space:nowrap}
#panel table td:last-child{color:var(--muted);text-align:right;font-size:10px;word-break:break-all}
#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}
/* Client indicators */
#panel .indicators{display:flex;flex-wrap:wrap;gap:4px 12px}
#panel .indicators span{font-size:11px;color:var(--muted);display:inline-flex;align-items:center;gap:5px}
.dot{width:6px;height:6px;border-radius:50%;display:inline-block;flex-shrink:0}
.d-on{background:var(--green)}.d-off{background:var(--red)}.d-warn{background:var(--warn)}
/* 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 */
/* Standards-track: Firefox 64+ / Chrome 121+ / Edge 121+ */
html,body,#preview-area,#panel{
scrollbar-width:thin;
scrollbar-color:rgba(107,125,153,.25) transparent;
}
/* IE / Legacy Edge */
#preview-area,#panel{
-ms-overflow-style:-ms-autohiding-scrollbar;
}
/* WebKit scrollbars — scoped to actual scrollable regions only */
#preview-area::-webkit-scrollbar,
#panel::-webkit-scrollbar { width:6px; height:6px }
#preview-area::-webkit-scrollbar-track,
#panel::-webkit-scrollbar-track { background:transparent }
#preview-area::-webkit-scrollbar-thumb,
#panel::-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,
#panel::-webkit-scrollbar-thumb:hover { background-color:rgba(107,125,153,.45) }
#preview-area::-webkit-scrollbar-corner,
#panel::-webkit-scrollbar-corner { background:transparent }
/* The panel's inner parameter section (flex:1;overflow-y:auto) */
#panel section::-webkit-scrollbar { width:6px; height:6px }
#panel section::-webkit-scrollbar-track { background:transparent }
#panel section::-webkit-scrollbar-thumb {
background-color:rgba(107,125,153,.25);
border-radius:3px;
border:1px solid rgba(255,255,255,.06);
}
#panel section::-webkit-scrollbar-thumb:hover { background-color:rgba(107,125,153,.45) }
#panel section::-webkit-scrollbar-corner { background:transparent }
#panel section{scrollbar-width:thin;scrollbar-color:rgba(107,125,153,.25) transparent}
@media(max-width:850px){#panel{display:none}#topbar .hint{display:none}}
</style>
</head>
<body>
<nav id="topbar">
<span class="brand">Gitea Mail Templates <span>Preview</span></span>
<span class="sep"></span>
<label>Theme</label>
<select id="sel-theme" onchange="switchTheme(this.value)"></select>
<span class="sep"></span>
<label>Template</label>
<select id="sel-template" onchange="switchTemplate(this.value)"></select>
<span class="sep"></span>
<label>Client</label>
<select id="sel-client" onchange="switchClient(this.value)">
<option value="modern">Modern</option><option value="gmail">Gmail</option>
<option value="outlook">Outlook</option><option value="raw">Raw Source</option>
</select>
<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>
<span class="hint">keys: d/m &nbsp; &larr;&rarr; tab &nbsp; &uarr;&darr; select</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">
<section>
<h3>Current Template</h3>
<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>
</section>
<section style="flex:1;overflow-y:auto">
<h3>Parameters</h3>
<table id="panel-params"><tr><td colspan="2" style="color:var(--muted)">-</td></tr></table>
</section>
<section>
<h3>Client Simulation</h3>
<div class="indicators" id="panel-indicators"></div>
</section>
<section>
<h3>Source</h3>
<div class="card" style="font-family:monospace;font-size:10px;color:var(--muted);word-break:break-all" id="panel-path">-</div>
</section>
</aside>
</div>
<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' };
let REGISTRY = {};
let PARAMS = {};
let rendered = null; // window.__RENDERED__ (modern / static fallback)
let renderedGmail = null; // window.__RENDERED_GMAIL__ (dev mode, Juice + Gmail strip)
let renderedOutlook = null; // window.__RENDERED_OUTLOOK__ (dev mode, Juice + Outlook strip)
let devMode = false; // true once WebSocket connects
let devCheckTimer = null; // delayed static-notice timer
//
// Client transforms
//
function transform(html, mode) {
// Raw source view — escape HTML for display
if(mode==='raw') {
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>';
}
// Modern, Gmail, Outlook — the HTML is pre-processed server-side.
// In dev mode rendered.js contains three variants (modern / gmail / outlook);
// the caller picks the right one before passing to transform().
return html;
}
/** Pick the right rendered object for the current client mode. */
function getRendered() {
if (!devMode) return rendered; // static: always modern
if (state.client==='gmail') return renderedGmail || rendered;
if (state.client==='outlook') return renderedOutlook || rendered;
return rendered;
}
//
// Render preview
//
function render() {
const frame = document.getElementById('preview-frame');
const loading = document.getElementById('loading');
if(!rendered) return; // no data yet — init handles the error display
// Sandbox only in dev mode; file: protocol rejects sandboxed srcdoc
if (location.protocol !== 'file:') {
frame.setAttribute('sandbox', '');
}
frame.style.display = 'none';
loading.style.display = 'flex';
const src = getRendered();
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>';
// Inject invisible scrollbar into iframe content for immersive preview
content = content.replace('</head>', '<style>html{scrollbar-color:rgba(107,125,153,.25) transparent;scrollbar-width:thin;-ms-overflow-style:-ms-autohiding-scrollbar}html::-webkit-scrollbar{width:6px;height:6px}html::-webkit-scrollbar-track{background:transparent}html::-webkit-scrollbar-thumb{background-color:rgba(107,125,153,.25);border-radius:3px;border:1px solid rgba(255,255,255,.06)}html::-webkit-scrollbar-thumb:hover{background-color:rgba(107,125,153,.45)}html::-webkit-scrollbar-corner{background:transparent}</style></head>');
let done = false;
function finish() {
if (done) return; done = true;
loading.style.display = 'none';
frame.style.display = 'block';
frame.onload = null;
}
frame.onload = finish;
frame.srcdoc = content;
// Safety timeout — force-hide loading if onload stalls (max 5 s)
setTimeout(finish, 5000);
updatePanel();
}
//
// Panel updates
//
function updatePanel() {
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 ? 'themes/'+state.theme+'/'+r.path : '-';
// 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 =
themes + ' themes loaded · ' + 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>.'+k+'</td><td>'+String(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>';
// Client simulation indicators
const c=state.client,
on='<span class="dot d-on"></span>',off='<span class="dot d-off"></span>',warn='<span class="dot d-warn"></span>',
items=[
[c==='raw'?off:(c==='gmail'||c==='outlook'?warn:on),'CSS'],
[c==='raw'?off:(c==='outlook'?warn:on),'Web Fonts'],
[c==='modern'?on:off,'Border Radius'],
[c==='modern'?on:off,'Shadows'],
[c==='modern'?on:off,'Gradients']
];
document.getElementById('panel-indicators').innerHTML = items.map(([d,label])=>'<span>'+d+' '+label+'</span>').join('');
}
//
// Events
//
function switchTheme(v) { state.theme=v; render(); }
function switchTemplate(v) { state.template=v; render(); }
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; }
// ← → cycle focus between Theme / Template / Client selects
// When a select dropdown is open the browser captures keys, so this only runs when closed
const selects=['sel-theme','sel-template','sel-client'];
const activeId=document.activeElement?.id;
const cur=selects.indexOf(activeId);
if(e.key==='ArrowRight'||e.key==='ArrowLeft'){
e.preventDefault();
if(cur>=0){
// Cycle to next/previous select
const dir=e.key==='ArrowRight'?1:-1;
const next=(cur+dir+selects.length)%selects.length;
document.getElementById(selects[next])?.focus();
} else {
// First press — jump to template (middle)
document.getElementById('sel-template')?.focus();
}
}
// ↑ ↓ are handled natively by the focused <select> — onchange fires → render()
});
//
// Init — load everything from rendered.js (single source of truth)
//
(function(){
// Load REGISTRY and PARAMS from rendered.js
if(window.__REGISTRY__) REGISTRY = window.__REGISTRY__;
if(window.__PARAMS__) PARAMS = window.__PARAMS__;
if(window.__RENDERED__){
rendered = window.__RENDERED__;
if(window.__RENDERED_GMAIL__) renderedGmail = window.__RENDERED_GMAIL__;
if(window.__RENDERED_OUTLOOK__) renderedOutlook = window.__RENDERED_OUTLOOK__;
// Populate theme selector from rendered data
const themeKeys = Object.keys(rendered).sort();
const selTheme = document.getElementById('sel-theme');
themeKeys.forEach(t => {
const o = document.createElement('option');
o.value = t;
o.textContent = t.charAt(0).toUpperCase() + t.slice(1);
selTheme.appendChild(o);
});
state.theme = themeKeys[0] || '';
// Populate template selector from REGISTRY
const selTpl = document.getElementById('sel-template');
for(const [id, t] of Object.entries(REGISTRY)){
const o = document.createElement('option');
o.value = id;
o.textContent = t.name;
selTpl.appendChild(o);
}
state.template = Object.keys(REGISTRY)[0] || '';
} else {
document.getElementById('loading').style.display='none';
showMessage('rendered.js not found — run <code>cd tools &amp;&amp; go run . preview all</code> to generate', 'error');
}
render();
})();
// 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 = '';
}
// Dev-mode detection
// Don't show static warnings if the dev server is running.
// The WebSocket open handler will call setDevMode() which clears anything stale.
function setDevMode(on) {
devMode = on;
if (devCheckTimer) { clearTimeout(devCheckTimer); devCheckTimer = null; }
if (on) {
clearMessages();
// Remove any stale static-mode notice injected by a previous check
const stale = document.getElementById('msg-static-notice');
if (stale) stale.remove();
// Dev-mode disclaimer — simulation is never 100% accurate
showMessage(
'Dev simulation cannot 100% reproduce every client — for reference only; always verify against real clients.',
'info'
).id = 'msg-dev-disclaimer';
}
}
// Static-mode notice (delayed — only shown if no WebSocket within 1 s)
devCheckTimer = setTimeout(function() {
devCheckTimer = null;
if (!devMode) {
showMessage(
'Gmail / Outlook simulation is approximate. For more accurate rendering with CSS inlining, use <code>cd tools && go run . dev</code>',
'warn'
).id = 'msg-static-notice';
}
}, 1000);
// Dev-server live-reload (WebSocket)
(function(){
let ws;
try {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + '/ws');
} catch(e) {
return;
}
ws.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() {
if (window.__RENDERED__) rendered = window.__RENDERED__;
if (window.__RENDERED_GMAIL__) renderedGmail = window.__RENDERED_GMAIL__;
if (window.__RENDERED_OUTLOOK__) renderedOutlook = window.__RENDERED_OUTLOOK__;
if (window.__REGISTRY__) REGISTRY = window.__REGISTRY__;
if (window.__PARAMS__) PARAMS = window.__PARAMS__;
render();
};
document.head.appendChild(script);
} else if (msg.type === 'error') {
showMessage(msg.message, 'error');
}
} catch(_) {}
};
ws.onopen = function() {
setDevMode(true);
console.log('[dev] live-reload connected');
};
ws.onclose = function() {
// Only alert if the server was previously connected (crash / shutdown)
if (devMode) {
showMessage('Dev server disconnected — reload the page to reconnect', 'error');
setDevMode(false);
}
console.log('[dev] live-reload disconnected');
};
ws.onerror = function() {
// onerror always fires before onclose — let onclose decide whether to show a message
console.log('[dev] WebSocket error (dev server may be offline)');
};
})();
</script>
</body>
</html>