Files
Tomas Dvorak f5b6f83974 dev day #99
2025-11-21 08:44:44 +01:00

273 lines
15 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fotbal Club — All Diagrams</title>
<style>
:root{
--bg:#0f1115;--panel:#0f131f;--text:#e8eaf0;--muted:#9aa3b2;--primary:#0b5cff;--border:#212736;--badge:#1b2440;
}
html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial}
header{position:sticky;top:0;z-index:10;background:#0d1017;border-bottom:1px solid var(--border);padding:12px 16px}
header h1{margin:0;font-size:16px;font-weight:700}
.filters{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:8px}
.filters input[type="search"], .filters select{background:#0f1420;color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 10px}
.filters .chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:16px;padding:6px 10px;background:#0f1420;cursor:pointer}
.filters .chip input{margin:0}
.filters .sp{flex:1}
.btn{appearance:none;border:1px solid var(--border);background:#11182a;color:var(--text);padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600;font-size:12px}
.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
.btn.ghost{background:transparent}
main{padding:16px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(640px,1fr));gap:16px}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
.card header{display:flex;align-items:center;gap:8px;justify-content:space-between;background:#0f131f;border-bottom:1px solid var(--border);padding:10px 12px;position:static}
.title{display:flex;flex-direction:column;gap:4px}
.title h2{margin:0;font-size:14px}
.meta{color:var(--muted);font-size:11px}
.badge{background:var(--badge);border:1px solid var(--border);border-radius:999px;padding:2px 8px;font-size:10px;color:#bcd}
.diagram-wrap{position:relative;overflow:auto;min-height:320px;background:#fff}
.diagram{padding:16px;min-height:320px}
.toolbar{display:flex;gap:8px;align-items:center;padding:8px 12px;border-top:1px solid var(--border);background:#0f131f;flex-wrap:wrap}
.toolbar .sp{flex:1}
.diagram svg{max-width:100%;height:auto;background:#fff}
.diagram svg text{fill:#111827 !important}
.diagram svg .edgePath path, .diagram svg .flowchart-link{stroke:#334155 !important}
.diagram svg .node > * { transition: filter .2s ease }
.diagram svg .node:hover { filter: drop-shadow(0 0 6px var(--primary)) }
</style>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({ startOnLoad:false, securityLevel:'loose', theme:'dark', flowchart:{ curve:'linear', useMaxWidth:true } });
async function renderMermaidFile(mmdPath, container){
try{
container.innerHTML = '<div style="padding:16px;color:#9aa3b2">Loading '+mmdPath+'…</div>';
const res = await fetch(mmdPath + '?v=' + Date.now(), { cache: 'no-store' });
if(!res.ok) throw new Error('Failed to load '+mmdPath+': '+res.status);
const raw = await res.text();
const code = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const id = 'm-'+Math.random().toString(36).slice(2);
const { svg } = await mermaid.render(id, code);
container.innerHTML = svg;
const svgEl = container.querySelector('svg');
if(svgEl){
svgEl.style.maxWidth = '100%';
svgEl.style.width = '100%';
svgEl.style.height = 'auto';
svgEl.removeAttribute('width');
svgEl.removeAttribute('height');
}
}catch(e){ container.innerHTML = '<div style="padding:16px;color:#ef4444">Error: '+(e && e.message ? e.message : e)+'</div>'; }
}
function downloadSVGOf(container, filename){
const svg = container.querySelector('svg');
if(!svg) return;
const serializer = new XMLSerializer();
let source = serializer.serializeToString(svg);
if(!source.match(/^<svg[^>]+xmlns=/)) source = source.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"');
source = '<?xml version="1.0" standalone="no"?>\n'+source;
const blob = new Blob([source], { type:'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = filename; a.click();
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
function openSVGInNewTab(container){
const svg = container.querySelector('svg');
if(!svg) return;
const serializer = new XMLSerializer();
let source = serializer.serializeToString(svg);
if(!source.match(/^<svg[^>]+xmlns=/)) source = source.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"');
source = '<?xml version="1.0" standalone="no"?>\n'+source;
// Inject white background and readable styles for new tab view
const svgStart = source.indexOf('<svg');
if(svgStart !== -1){
const svgTagEnd = source.indexOf('>', svgStart);
if(svgTagEnd !== -1){
const inject = '<rect width="100%" height="100%" fill="#ffffff"/><style>text{fill:#111827}.edgePath path,.flowchart-link{stroke:#334155}</style>';
source = source.slice(0, svgTagEnd+1) + inject + source.slice(svgTagEnd+1);
}
}
const blob = new Blob([source], { type:'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
const ALL_DIAGRAMS = [
// System & DB
{ id:'system-clean', label:'System Overview', file:'system-overall-clean.mmd', cat:'System', tags:['overview','recommended','big'] },
{ id:'db-er', label:'Database ER', file:'db-er.mmd', cat:'System', tags:['db'] },
{ id:'db-models', label:'Database Models', file:'db-models.mmd', cat:'System', tags:['db'] },
// Backend
{ id:'be-routes', label:'Backend Routes Overview', file:'backend-routes-overview.mmd', cat:'Backend', tags:['routes'] },
{ id:'be-packages', label:'Backend Packages', file:'backend-packages.mmd', cat:'Backend', tags:['packages'] },
{ id:'be-mw', label:'Backend Middleware Pipeline', file:'backend-middleware-pipeline.mmd', cat:'Backend', tags:['middleware'] },
{ id:'be-jobs', label:'Backend Background Jobs', file:'backend-jobs.mmd', cat:'Backend', tags:['jobs'] },
{ id:'auth', label:'Auth Flow', file:'auth-flow.mmd', cat:'Backend', tags:['auth','flow'] },
{ id:'err-flow', label:'Error Tracking Flow', file:'error-tracking-flow.mmd', cat:'Backend', tags:['errors','flow'] },
// Frontend
{ id:'fe-everything', label:'Frontend — Everything (Big)', file:'frontend-everything.mmd', cat:'Frontend', tags:['overview','big','recommended'], defaultWires:'faint' },
{ id:'fe-overall', label:'Frontend — Overall', file:'frontend-overall.mmd', cat:'Frontend', tags:['architecture','recommended'] },
{ id:'fe-routes', label:'Frontend — Routes', file:'frontend-routes.mmd', cat:'Frontend', tags:['routes'] },
{ id:'fe-home', label:'Frontend — Homepage', file:'frontend-homepage.mmd', cat:'Frontend', tags:['homepage'] },
{ id:'fe-api', label:'Frontend — API Map', file:'frontend-api-map.mmd', cat:'Frontend', tags:['api'] },
// Admin
{ id:'admin-overall', label:'Admin — Overall', file:'admin-overall.mmd', cat:'Admin', tags:['admin','overview','recommended'], defaultWires:'faint' },
{ id:'scoreboard', label:'Scoreboard Flow', file:'scoreboard-flow.mmd', cat:'Admin', tags:['scoreboard','flow'] },
{ id:'newsletter', label:'Newsletter Flow', file:'newsletter-flow.mmd', cat:'Admin', tags:['newsletter','flow'] },
{ id:'comments', label:'Comments Flow', file:'comments-flow.mmd', cat:'Admin', tags:['comments','flow'] },
{ id:'gallery-zonerama', label:'Gallery (Zonerama) Flow', file:'gallery-zonerama-flow.mmd', cat:'Admin', tags:['gallery','flow'] },
{ id:'shortlinks', label:'Shortlinks Flow', file:'shortlinks-flow.mmd', cat:'Admin', tags:['shortlinks','flow'] },
{ id:'upload-flow', label:'Upload Flow', file:'upload-flow.mmd', cat:'Admin', tags:['upload','flow'] },
];
function createCard(d){
const sec = document.createElement('section');
sec.className = 'card';
sec.id = 'card-'+d.id;
sec.dataset.cat = d.cat;
sec.dataset.tags = (d.tags||[]).join(',');
// No default wires styling; keep full visibility
const h = document.createElement('header');
const title = document.createElement('div'); title.className = 'title';
const h2 = document.createElement('h2'); h2.textContent = d.label; title.appendChild(h2);
const meta = document.createElement('div'); meta.className='meta'; meta.textContent = d.file + ' • ' + d.cat; title.appendChild(meta);
const right = document.createElement('div');
const badge = document.createElement('span'); badge.className = 'badge'; badge.textContent = d.cat; right.appendChild(badge);
h.appendChild(title); h.appendChild(right);
const wrap = document.createElement('div'); wrap.className='diagram-wrap';
const diag = document.createElement('div'); diag.className='diagram'; diag.dataset.file = d.file; wrap.appendChild(diag);
const tb = document.createElement('div'); tb.className='toolbar';
tb.innerHTML = `
<label style="display:inline-flex;align-items:center;gap:8px"><input type="checkbox" class="fit" checked> Fit width</label>
<label style="display:inline-flex;align-items:center;gap:6px">Zoom <input class="zoom" type="range" min="50" max="300" value="100" style="width:140px"></label>
<a class="btn ghost src" href="${d.file}" target="_blank">Source</a>
<span class="sp"></span>
<button class="btn open">Open SVG in new tab</button>
<button class="btn refresh">Refresh</button>
<button class="btn primary download">Download SVG</button>`;
sec.appendChild(h); sec.appendChild(wrap); sec.appendChild(tb);
return sec;
}
function applyFitZoomFor(card){
const container = card.querySelector('.diagram');
const svg = container?.querySelector('svg');
if(!svg) return;
const fit = card.querySelector('.fit');
const zoom = card.querySelector('.zoom');
if(fit && fit.checked){
svg.style.width='100%'; svg.style.height='auto';
svg.style.transformOrigin = '';
svg.style.transform = '';
} else {
svg.style.width=''; svg.style.height='';
const z = Math.max(50, Math.min(300, parseInt(zoom?.value || '100', 10)));
svg.style.transformOrigin = 'top left';
svg.style.transform = 'scale('+(z/100)+')';
}
}
function wireCardControls(card, file){
const diag = card.querySelector('.diagram');
card.dataset.file = file;
const fit = card.querySelector('.fit');
const openBtn = card.querySelector('.open');
const refresh = card.querySelector('.refresh');
const download = card.querySelector('.download');
const zoom = card.querySelector('.zoom');
fit.addEventListener('change', () => applyFitZoomFor(card));
zoom.addEventListener('input', () => applyFitZoomFor(card));
openBtn.addEventListener('click', () => openSVGInNewTab(diag));
refresh.addEventListener('click', async () => { diag.dataset.rendered=''; await renderMermaidFile(file, diag); diag.dataset.rendered='1'; applyFitZoomFor(card); });
download.addEventListener('click', () => downloadSVGOf(diag, (file.replace('.mmd','')||'diagram')+'.svg'));
}
let observer;
function setupObserver(){
if(observer) observer.disconnect();
observer = new IntersectionObserver(entries => {
entries.forEach(async e => {
if(e.isIntersecting){
const card = e.target; const diag = card.querySelector('.diagram');
if(diag && !diag.dataset.rendered){
await renderMermaidFile(diag.dataset.file, diag); diag.dataset.rendered='1'; applyFitZoomFor(card);
}
}
});
}, { root:null, rootMargin:'200px', threshold:0 });
document.querySelectorAll('.card').forEach(c => observer.observe(c));
}
function buildGrid(){
const grid = document.getElementById('grid');
grid.innerHTML='';
for(const d of ALL_DIAGRAMS){ const card = createCard(d); grid.appendChild(card); wireCardControls(card, d.file); }
setupObserver();
}
function applyFilters(){
const q = (document.getElementById('search').value || '').toLowerCase();
const cats = Array.from(document.querySelectorAll('input[name="cat-filter"]:checked')).map(i=>i.value);
document.querySelectorAll('.card').forEach(card => {
const label = card.querySelector('h2').textContent.toLowerCase();
const file = card.dataset.file || '';
const cat = card.dataset.cat;
const okCat = cats.length===0 || cats.includes(cat);
const okText = !q || label.includes(q) || file.toLowerCase().includes(q) || (card.dataset.tags||'').toLowerCase().includes(q);
card.style.display = (okCat && okText) ? '' : 'none';
});
}
function globalAction(action){
if(action==='refresh-visible'){
document.querySelectorAll('.card').forEach(async card => {
if(card.offsetParent!==null){
const diag = card.querySelector('.diagram');
if(diag){ diag.dataset.rendered=''; await renderMermaidFile(diag.dataset.file, diag); diag.dataset.rendered='1'; applyFitZoomFor(card); }
}
});
}
if(action==='expand-all'){ document.querySelectorAll('.diagram-wrap').forEach(w => w.style.maxHeight=''); }
if(action==='collapse-all'){ document.querySelectorAll('.diagram-wrap').forEach(w => w.style.maxHeight='200px'); }
}
window.addEventListener('DOMContentLoaded', () => {
buildGrid();
document.querySelectorAll('input[name="cat-filter"]').forEach(i=> i.addEventListener('change', applyFilters));
document.getElementById('search').addEventListener('input', applyFilters);
document.getElementById('refreshVisible').addEventListener('click', () => globalAction('refresh-visible'));
document.getElementById('expandAll').addEventListener('click', () => globalAction('expand-all'));
document.getElementById('collapseAll').addEventListener('click', () => globalAction('collapse-all'));
});
</script>
</head>
<body>
<header>
<h1>Fotbal Club — Unified Diagrams</h1>
<div class="filters">
<input id="search" type="search" placeholder="Search diagrams, files, tags…" />
<label class="chip"><input type="checkbox" name="cat-filter" value="System" /> System</label>
<label class="chip"><input type="checkbox" name="cat-filter" value="Backend" /> Backend</label>
<label class="chip"><input type="checkbox" name="cat-filter" value="Frontend" /> Frontend</label>
<label class="chip"><input type="checkbox" name="cat-filter" value="Admin" /> Admin</label>
<span class="sp"></span>
<button class="btn" id="refreshVisible">Refresh visible</button>
<button class="btn ghost" id="expandAll">Expand all</button>
<button class="btn ghost" id="collapseAll">Collapse all</button>
</div>
</header>
<main>
<div id="grid" class="grid"></div>
</main>
</body>
</html>