The _patch.cjs script accidentally stripped </script></body></html> when replacing the WebSocket section, causing the inline JavaScript to never terminate — the browser parsed everything as script content and all functions were undefined. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
364 lines
16 KiB
HTML
364 lines
16 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:270px;--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 code{background:rgba(0,0,0,0.12);padding:1px 6px;border-radius:3px}
|
|
|
|
#panel::-webkit-scrollbar{width:4px}
|
|
#panel::-webkit-scrollbar-track{background:transparent}
|
|
#panel::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
|
|
|
|
@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 ←→</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" sandbox="allow-same-origin" 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__
|
|
|
|
//
|
|
// Client transforms
|
|
//
|
|
function transform(html, mode) {
|
|
// Raw source view — escape HTML for display
|
|
if(mode==='raw') {
|
|
const e = html.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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 — all show the same rendered HTML.
|
|
// In dev mode (go run . dev), the server applies Juice CSS inlining
|
|
// before serving — the output is already email-optimised.
|
|
// In static mode, the notice bar warns that simulation is approximate.
|
|
return html;
|
|
}
|
|
|
|
//
|
|
// Render preview
|
|
//
|
|
function render() {
|
|
const frame = document.getElementById('preview-frame');
|
|
const loading = document.getElementById('loading');
|
|
if(!rendered) { loading.style.display='flex'; frame.style.display='none'; return; }
|
|
|
|
frame.style.display = 'none';
|
|
loading.style.display = 'flex';
|
|
|
|
const html = rendered[state.theme]?.[state.template];
|
|
const content = html ? transform(html, state.client) : '<p style="color:red;padding:20px">Not found: '+state.theme+'/'+state.template+'</p>';
|
|
|
|
frame.onload = function() {
|
|
loading.style.display = 'none';
|
|
frame.style.display = 'block';
|
|
frame.onload = null;
|
|
};
|
|
frame.srcdoc = content;
|
|
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=>{
|
|
if(e.key==='d') setViewport('desktop');
|
|
if(e.key==='m') setViewport('mobile');
|
|
const keys=Object.keys(REGISTRY), idx=keys.indexOf(state.template);
|
|
if(e.key==='ArrowRight'&&idx<keys.length-1){switchTemplate(keys[idx+1]);document.getElementById('sel-template').value=keys[idx+1];}
|
|
if(e.key==='ArrowLeft'&&idx>0){switchTemplate(keys[idx-1]);document.getElementById('sel-template').value=keys[idx-1];}
|
|
});
|
|
|
|
//
|
|
// 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__;
|
|
|
|
// 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] || '';
|
|
|
|
document.getElementById('loading').style.display='none';
|
|
} else {
|
|
document.getElementById('loading').style.display='none';
|
|
showMessage('rendered.js not found — run <code>cd tools && 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 = '';
|
|
}
|
|
|
|
// ── Static-mode notice ──
|
|
showMessage('Gmail / Outlook simulation is approximate. For accurate rendering with CSS inlining, use <code>cd tools && go run . dev</code>', 'warn');
|
|
|
|
// ── 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.__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() {
|
|
clearMessages();
|
|
console.log('[dev] live-reload connected');
|
|
};
|
|
ws.onclose = function() {
|
|
showMessage('Dev server disconnected', 'error');
|
|
console.log('[dev] live-reload disconnected');
|
|
};
|
|
ws.onerror = function() {
|
|
showMessage('Dev server connection failed', 'error');
|
|
};
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|