feat: Scorecard-Werkstatt — Live-Editor unter /v2/scorecard-werkstatt

User-Wunsch: 'Baue eine Entwicklungsseite, wo wir all das in CSS code
zusammenschreiben und länger daran arbeiten können ohne jedes mal png
erzeugen zu müssen. Können wir hinterher auch nutzen, um irgendwo
mal schnell eine Übersicht einzublenden.'

Neue Route /v2/scorecard-werkstatt mit Split-Layout:
- Links: Live-iframe-Vorschau der /v2/scorecard, mit Zoom-Toolbar
  (Fit / 40 / 50 / 65 / 80 / 100 %).
- Rechts: Drucksachen-Selector (Top-60 Anträge), Format-Pills
  (Portrait / Square / OG), CSS-Editor-Textarea + Apply-Button.
- Apply schreibt das User-CSS als <style>-Element in den iframe →
  keine Server-Roundtrips, kein PNG-Render, instantane Iteration.
- Strg/⌘+Enter im Editor wendet sofort an. Tab fuegt 2 Spaces ein.
- Direkt-Link + Iframe-Snippet werden generiert — die Card laesst sich
  also direkt embedden (z.B. Übersicht in einer anderen App).

Plus: Cache-Buster `&_=Date.now()` am Scorecard-Button im v3-Detail,
damit die Vorschau-Anzeige nach Layout-Aenderungen nicht weiter eine
gecachete Version zeigt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-05-07 13:39:45 +02:00
parent 4734e89522
commit 8ae2b92313
3 changed files with 426 additions and 5 deletions

View File

@ -3473,6 +3473,31 @@ async def og_template(request: Request, drucksache: str = ""):
})
@app.get("/v2/scorecard-werkstatt", response_class=HTMLResponse)
async def scorecard_werkstatt(request: Request):
"""Live-Sandbox für Scorecard-Design-Iteration.
Zeigt eine Vorschau-iframe der /v2/scorecard und einen CSS-Live-Editor.
Kein DB-Lookup nötig der Editor passt nur Styles an, der Inhalt kommt
von der gewählten Drucksache. Plus Embed-Link-Generator für die
fertige Ansicht in anderen Kontexten.
"""
rows = await get_all_assessments(None)
drucksachen = []
for r in rows[:60]:
drucksachen.append({
"drucksache": r.get("drucksache", ""),
"bundesland": r.get("bundesland", ""),
"title": (r.get("title") or "")[:80],
})
response = templates.TemplateResponse("v2/screens/scorecard_werkstatt.html", {
"request": request,
"drucksachen": drucksachen,
})
response.headers["Cache-Control"] = "no-store"
return response
@app.get("/v2/scorecard")
async def scorecard_template(
request: Request, drucksache: str, bundesland: str = "NRW",

View File

@ -934,13 +934,13 @@ window.v2ShowMatrixFieldInfo = function(field) {
window.location.href = 'mailto:?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body);
};
/* Scorecard-Preview — Default ist portrait (1080×1350, Instagram 4:5).
Format-Param bewusst NICHT gesetzt, damit sich der Server-Default
durchsetzt — sonst muss man hier mitziehen wenn der Default sich
wieder aendert. */
/* Scorecard-Preview — Default portrait (1080×1350). Cache-Buster
erzwingt frischen Server-Render, sonst zeigt der Browser eine
alte gecachete HTML-Variante. */
window.v2DetailShareScorecard = function() {
var url = '/v2/scorecard?drucksache=' + encodeURIComponent(DRS)
+ '&bundesland=' + encodeURIComponent(BL || 'NRW');
+ '&bundesland=' + encodeURIComponent(BL || 'NRW')
+ '&_=' + Date.now();
window.open(url, '_blank', 'noopener');
};

View File

@ -0,0 +1,396 @@
{% extends "v2/base.html" %}
{% block title %}Scorecard-Werkstatt — GWÖ-Antragsprüfer{% endblock %}
{% set v2_active_nav = "" %}
{% block head_extra %}
<style>
.ws-shell {
display: grid;
grid-template-columns: 1fr 480px;
gap: 18px;
height: calc(100vh - 100px);
min-height: 700px;
}
@media (max-width: 1100px) {
.ws-shell { grid-template-columns: 1fr; height: auto; }
}
.ws-preview {
background: var(--ecg-bg-subtle);
border: 1px solid var(--ecg-border);
border-radius: 4px;
padding: 14px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.ws-preview iframe {
border: 1px solid var(--ecg-border);
background: #fff;
/* iframe-Skalierung: 1080×1350 → ein bisschen verkleinert anzeigen,
Skalierung pro Format konfigurierbar via JS */
transform-origin: top left;
background: #fff;
}
.ws-frame-wrap {
overflow: hidden;
background: #fff;
border: 1px solid var(--ecg-border);
border-radius: 4px;
}
.ws-controls {
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
padding-right: 6px;
}
.ws-section {
border: 1px solid var(--ecg-border);
border-radius: 4px;
padding: 12px 14px;
background: var(--ecg-card-bg);
}
.ws-section h3 {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ecg-blue);
margin: 0 0 8px;
font-weight: 700;
}
.ws-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 6px;
}
.ws-row label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--ecg-dark);
opacity: 0.75;
min-width: 90px;
}
.ws-row select,
.ws-row input[type="text"] {
flex: 1;
padding: 4px 8px;
border: 1px solid var(--ecg-border);
border-radius: 3px;
font-family: var(--font-mono);
font-size: 11px;
background: var(--paper);
color: var(--ecg-dark);
}
.ws-format-pills {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.ws-format-pills button {
padding: 4px 10px;
border: 1px solid var(--ecg-border);
border-radius: 3px;
background: var(--paper);
color: var(--ecg-dark);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
}
.ws-format-pills button.active {
background: var(--ecg-blue);
color: #fff;
border-color: var(--ecg-blue);
}
textarea.ws-css-editor {
width: 100%;
min-height: 320px;
flex: 1;
padding: 10px 12px;
border: 1px solid var(--ecg-border);
border-radius: 3px;
font-family: var(--font-mono);
font-size: 11px;
background: #1f1f1f;
color: #d8e4d8;
line-height: 1.5;
resize: vertical;
tab-size: 2;
box-sizing: border-box;
}
.ws-css-actions {
display: flex;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
.ws-css-actions button {
padding: 6px 14px;
border: 1px solid var(--ecg-border);
border-radius: 3px;
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
background: var(--paper);
color: var(--ecg-dark);
}
.ws-css-actions button.primary {
background: var(--ecg-blue);
border-color: var(--ecg-blue);
color: #fff;
font-weight: 700;
}
.ws-meta {
font-family: var(--font-mono);
font-size: 10px;
color: var(--ecg-dark);
opacity: 0.65;
line-height: 1.5;
}
.ws-embed-row code {
display: block;
padding: 8px 10px;
background: var(--ecg-bg-subtle);
border: 1px solid var(--ecg-border);
border-radius: 3px;
font-family: var(--font-mono);
font-size: 10px;
word-break: break-all;
user-select: all;
}
.ws-toolbar {
display: flex;
gap: 6px;
margin-bottom: 4px;
flex-wrap: wrap;
align-items: center;
}
.ws-toolbar button {
padding: 3px 9px;
border: 1px solid var(--ecg-border);
border-radius: 3px;
background: var(--paper);
color: var(--ecg-dark);
font-family: var(--font-mono);
font-size: 10px;
cursor: pointer;
}
.ws-toolbar span.zoom-info {
font-family: var(--font-mono);
font-size: 10px;
color: var(--ecg-dark);
opacity: 0.6;
}
</style>
{% endblock %}
{% block main %}
<div style="padding:0 0 1rem;">
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Scorecard-Werkstatt</h1>
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.7;">
Live-Editor für Scorecard-Layout. Drucksache + Format wählen, CSS im rechten Editor anpassen,
Apply drücken — Vorschau aktualisiert ohne neue PNG-Render. Auch als Embed-Quelle für
eingebettete Übersichten in anderen Kontexten nutzbar.
</p>
</div>
<div class="ws-shell">
{# ── Vorschau links ──────────────────────────────────────────────── #}
<div class="ws-preview">
<div class="ws-toolbar">
<button onclick="wsZoomFit()">Fit</button>
<button onclick="wsZoom(0.4)">40 %</button>
<button onclick="wsZoom(0.5)">50 %</button>
<button onclick="wsZoom(0.65)">65 %</button>
<button onclick="wsZoom(0.8)">80 %</button>
<button onclick="wsZoom(1.0)">100 %</button>
<span class="zoom-info" id="ws-zoom-info"></span>
</div>
<div class="ws-frame-wrap" id="ws-frame-wrap">
<iframe id="ws-frame" src="about:blank" title="Scorecard-Vorschau"></iframe>
</div>
<div class="ws-meta" id="ws-meta">Drucksache + Format wählen.</div>
</div>
{# ── Controls + CSS-Editor rechts ────────────────────────────────── #}
<div class="ws-controls">
<div class="ws-section">
<h3>Inhalt</h3>
<div class="ws-row">
<label>Drucksache</label>
<select id="ws-drs" onchange="wsRender()">
{% for d in drucksachen %}
<option value="{{ d.drucksache }}|{{ d.bundesland }}">
{{ d.bundesland }} · {{ d.drucksache }} — {{ d.title }}
</option>
{% endfor %}
</select>
</div>
<div class="ws-row">
<label>Format</label>
<div class="ws-format-pills">
<button data-format="portrait" class="active" onclick="wsSetFormat('portrait')">Portrait 4:5 (1080×1350)</button>
<button data-format="square" onclick="wsSetFormat('square')">Square 1:1 (1080×1080)</button>
<button data-format="og" onclick="wsSetFormat('og')">OG 16:8 (1200×630)</button>
</div>
</div>
</div>
<div class="ws-section" style="display:flex;flex-direction:column;flex:1;min-height:280px;">
<h3>CSS-Override (live)</h3>
<textarea class="ws-css-editor" id="ws-css" placeholder="/* CSS-Selektoren in den iframe-Kontext.
Beispiel:
.portrait-title { font-size: 28pt; }
.portrait-score-num { font-size: 150pt; }
.cell.r-0 { background: #c8c8c2; }
*/"></textarea>
<div class="ws-css-actions">
<button class="primary" onclick="wsApplyCss()">Anwenden ⏎</button>
<button onclick="wsResetCss()">Zurücksetzen</button>
<button onclick="wsCopyCss()">CSS kopieren</button>
</div>
<div class="ws-meta" style="margin-top:6px;">
Tipp: Strg/⌘+Enter wendet das CSS sofort an. Reset entfernt nur den Override —
die Server-CSS bleibt.
</div>
</div>
<div class="ws-section ws-embed-row">
<h3>Direkt-Link / Embed</h3>
<code id="ws-embed-url"></code>
<div class="ws-meta" style="margin-top:6px;">
Iframe-Snippet:
</div>
<code id="ws-embed-iframe" style="margin-top:4px;"></code>
</div>
</div>
</div>
{% endblock %}
{% block body_scripts %}
<script>
(function () {
var drsSel = document.getElementById('ws-drs');
var iframe = document.getElementById('ws-frame');
var frameWrap = document.getElementById('ws-frame-wrap');
var cssEditor = document.getElementById('ws-css');
var meta = document.getElementById('ws-meta');
var embedUrl = document.getElementById('ws-embed-url');
var embedIframe = document.getElementById('ws-embed-iframe');
var zoomInfo = document.getElementById('ws-zoom-info');
var currentFormat = 'portrait';
var formatDims = {
portrait: [1080, 1350],
square: [1080, 1080],
og: [1200, 630],
};
function getCurrent() {
var v = drsSel.value || '';
var parts = v.split('|');
return { drs: parts[0] || '', bl: parts[1] || 'NRW' };
}
window.wsSetFormat = function (f) {
currentFormat = f;
document.querySelectorAll('.ws-format-pills button').forEach(function (b) {
b.classList.toggle('active', b.dataset.format === f);
});
wsRender();
};
window.wsRender = function () {
var c = getCurrent();
if (!c.drs) return;
var src = '/v2/scorecard'
+ '?drucksache=' + encodeURIComponent(c.drs)
+ '&bundesland=' + encodeURIComponent(c.bl)
+ '&format=' + currentFormat
+ '&_=' + Date.now();
iframe.src = src;
var dims = formatDims[currentFormat];
iframe.width = dims[0];
iframe.height = dims[1];
iframe.style.width = dims[0] + 'px';
iframe.style.height = dims[1] + 'px';
meta.textContent = c.bl + ' · ' + c.drs + ' · ' + currentFormat
+ ' (' + dims[0] + '×' + dims[1] + ')';
var publicUrl = window.location.origin + '/v2/scorecard'
+ '?drucksache=' + encodeURIComponent(c.drs)
+ '&bundesland=' + encodeURIComponent(c.bl)
+ '&format=' + currentFormat;
embedUrl.textContent = publicUrl;
embedIframe.textContent = '<iframe src="' + publicUrl + '" '
+ 'width="' + dims[0] + '" height="' + dims[1] + '" '
+ 'frameborder="0"></iframe>';
setTimeout(wsZoomFit, 100);
iframe.addEventListener('load', wsApplyCss, { once: true });
};
window.wsApplyCss = function () {
var doc = iframe.contentDocument;
if (!doc) return;
var styleId = 'ws-override-style';
var existing = doc.getElementById(styleId);
if (existing) existing.remove();
var style = doc.createElement('style');
style.id = styleId;
style.textContent = cssEditor.value;
doc.head.appendChild(style);
};
window.wsResetCss = function () {
cssEditor.value = '';
wsApplyCss();
};
window.wsCopyCss = function () {
if (navigator.clipboard && cssEditor.value) {
navigator.clipboard.writeText(cssEditor.value);
}
};
window.wsZoom = function (factor) {
iframe.style.transform = 'scale(' + factor + ')';
var dims = formatDims[currentFormat];
frameWrap.style.width = (dims[0] * factor) + 'px';
frameWrap.style.height = (dims[1] * factor) + 'px';
zoomInfo.textContent = Math.round(factor * 100) + '%';
};
window.wsZoomFit = function () {
var avail = document.querySelector('.ws-preview').clientWidth - 30;
var dims = formatDims[currentFormat];
var factor = Math.min(1.0, avail / dims[0]);
factor = Math.round(factor * 100) / 100;
wsZoom(factor);
};
cssEditor.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
wsApplyCss();
}
if (e.key === 'Tab') {
e.preventDefault();
var s = this.selectionStart;
this.value = this.value.substring(0, s) + ' ' + this.value.substring(this.selectionEnd);
this.selectionStart = this.selectionEnd = s + 2;
}
});
wsRender();
})();
</script>
{% endblock %}