2026-03-08 07:36:37 +01:00
<!doctype html> < html > < head > < meta charset = "utf-8" > < meta name = "viewport" content = "width=device-width, initial-scale=1" / >
< style >
@page { size: A4 portrait; margin: 1cm; }
body { font-family: 'Helvetica','Arial',sans-serif; background:#f0f0f0; margin:0; padding:0; }
body.modal-open { overflow: hidden; }
.topbar { height:58px; background:#1f2937; color:#fff; display:flex; align-items:center; gap:12px; padding:0 14px; position:sticky; top:0; z-index:20; box-shadow:0 2px 8px rgba(0,0,0,.2); }
.menu-btn { width:34px; height:34px; border:none; border-radius:8px; background:#334155; color:#fff; cursor:pointer; font-size:18px; }
.brand { display:flex; align-items:center; gap:8px; font-weight:700; }
.brand .emoji { font-size:20px; }
.layout { display:flex; min-height:calc(100vh - 58px); }
.sidebar { width:260px; background:#0f172a; color:#dbeafe; padding:14px; box-sizing:border-box; transform:translateX(-100%); transition:transform .2s ease; position:fixed; top:58px; bottom:0; left:0; z-index:30; }
.sidebar.open { transform:translateX(0); }
.sidebar a, .sidebar button { display:block; width:100%; text-align:left; margin:0 0 8px 0; padding:10px 12px; border-radius:8px; border:none; background:#1e293b; color:#e2e8f0; text-decoration:none; cursor:pointer; }
.main { flex:1; padding:20px; display:flex; justify-content:center; }
#app { width:100%; display:flex; flex-direction:column; align-items:center; }
.ai-spin { width:14px; height:14px; border:2px solid #93c5fd; border-top-color:#1d4ed8; border-radius:999px; display:inline-block; animation:spin .9s linear infinite; vertical-align:middle; }
@keyframes spin { to { transform:rotate(360deg); } }
.wysiwyg-wrap { margin-top:8px; border:1px solid #cbd5e1; border-radius:10px; overflow:hidden; }
#aiPromptEditor { min-height:360px; }
.card-size { width:18cm; height:13cm; background:white; border:1px solid #ccc; border-radius:8px; padding:.8cm 1cm; box-sizing:border-box; position:relative; display:flex; flex-direction:column; box-shadow:0 4px 10px rgba(0,0,0,.1); overflow:visible; margin-bottom:20px; page-break-inside:avoid; }
.card-actions { position:absolute; right:-48px; top:10px; display:flex; flex-direction:column; gap:8px; z-index:5; }
.card-action-btn { width:38px; height:38px; border-radius:999px; border:none; background:#4A90E2; color:#fff; font-size:16px; cursor:pointer; box-shadow:0 2px 6px rgba(0,0,0,.25); }
.card-action-btn.output { background:#0ea5a5; }
.chapter-card { background:#2C3E50; color:white; justify-content:center; text-align:center; border-bottom:8px solid #4A90E2; }
.chapter-card h1 { font-size:24pt; color:#4A90E2; margin:0 0 10px 0; }
.exercise-container { display:flex; flex-direction:column; gap:10px; page-break-after:always; }
.header-row { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:10px; border-bottom:1px solid #eee; padding-bottom:5px; flex-shrink:0; }
.title { font-size:18pt; font-weight:bold; color:#2C3E50; margin:0; flex:1; }
.duration { font-size:11pt; color:#4A90E2; font-weight:bold; margin-left:10px; }
.step-item { margin:0 0 8px 0; line-height:1.4; font-size:10.5pt; color:#333; }
.columns-wrapper { display:block; column-count:2; column-gap:30px; column-fill:auto; flex-grow:1; height:7cm; text-align:left; }
.langtext-seite1-layout { display:flex; flex-direction:column; flex-grow:1; overflow:hidden; min-height:0; }
.langtext-top-band { display:flex; gap:15px; flex-shrink:0; background:#EBF4FF; border-radius:6px; padding:8px 10px; margin-bottom:10px; border-left:4px solid #4A90E2; }
.langtext-inhalt-seite1 { column-count:2; column-gap:30px; column-fill:auto; flex:1 1 auto; min-height:0; height:100%; overflow:hidden; text-align:left; }
.image-placeholder-main, .image-placeholder-small, .image-placeholder-normal { display:flex; align-items:center; justify-content:center; cursor:pointer; text-align:center; overflow:hidden; }
.image-placeholder-main img,.image-placeholder-small img,.image-placeholder-normal img { width:100%; height:100%; object-fit:cover; }
.image-placeholder-main { flex:0 0 40%; background:#d6e8f7; border:1px dashed #4A90E2; color:#7aafd4; font-size:9pt; border-radius:4px; }
.image-placeholder-small { width:100%; height:3cm; background:#f9f9f9; border:1px dashed #ccc; color:#bbb; font-size:8pt; margin-top:15px; break-inside:avoid; }
.image-placeholder-normal { flex:0 0 45%; background:#f9f9f9; border:1px dashed #ccc; color:#bbb; font-size:9pt; }
.short-desc { flex:1; font-size:10.5pt; line-height:1.4; color:#333; }
.tip-box-full { width:calc(100% + 2cm); margin-left:-1cm; margin-top:auto; padding:10px 1cm; background:#FFF9C4; border-top:2px solid #FBC02D; font-size:10.5pt; line-height:1.3; color:#2C3E50; display:flex; gap:12px; align-items:center; box-sizing:border-box; flex-shrink:0; }
.tip-icon { font-size:14pt; }
.editable { cursor:pointer; }
#modal { position:fixed; inset:0; background:rgba(0,0,0,.45); display:none; align-items:center; justify-content:center; z-index:50; }
#modal .box { width:min(700px,95vw); background:#fff; padding:14px; border-radius:8px; }
#editor { width:100%; height:220px; }
#modalAll { position:fixed; inset:0; background:rgba(15,23,42,.45); backdrop-filter: blur(6px); display:none; align-items:center; justify-content:center; z-index:60; padding:20px; }
#modalAll .box { width:min(980px,96vw); max-height:92vh; overflow:auto; background:linear-gradient(180deg,#ffffff,#f8fbff); padding:0; border-radius:16px; box-shadow:0 20px 60px rgba(15,23,42,.25), 0 2px 12px rgba(15,23,42,.12); border:1px solid #dbe7ff; }
.modal-header { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; border-bottom:1px solid #e2e8f0; background:#f8fbff; border-radius:16px 16px 0 0; }
.modal-header h3 { margin:0; font-size:20px; color:#1f2a44; }
.modal-close { border:none; background:transparent; font-size:22px; cursor:pointer; color:#64748b; }
.modal-body { padding:14px 16px; }
.form-grid { display:flex; flex-direction:column; gap:10px; }
.form-row { display:grid; grid-template-columns: 1fr 180px 140px 140px; gap:10px; }
.form-row.three { grid-template-columns: 1fr 140px 140px; }
.form-row.two { grid-template-columns: 1fr 1fr; }
.field { display:flex; flex-direction:column; gap:6px; }
.field label { font-weight:700; color:#334155; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
.field input,.field textarea,.field select { width:100%; box-sizing:border-box; padding:10px 12px; border:1px solid #cbd5e1; border-radius:10px; background:#fff; font-size:14px; color:#0f172a; outline:none; }
.field input:focus,.field textarea:focus,.field select:focus { border-color:#4A90E2; box-shadow:0 0 0 3px rgba(74,144,226,.18); }
.field textarea { min-height:80px; font-family:inherit; line-height:1.45; resize:vertical; }
.field.compact textarea { min-height:58px; }
.check-wrap { display:flex; align-items:center; gap:8px; height:42px; }
.check-wrap input[type='checkbox']{ width:18px; height:18px; }
.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:14px; }
.btn { padding:10px 14px; border-radius:10px; border:1px solid transparent; font-weight:600; cursor:pointer; }
.btn-primary { background:#2563eb; color:#fff; }
.btn-primary:hover { background:#1d4ed8; }
.btn-secondary { background:#fff; color:#334155; border-color:#cbd5e1; }
.btn-secondary:hover { background:#f8fafc; }
#split-probe { position:absolute; visibility:hidden; pointer-events:none; top:0; left:-9999px; width:18cm; height:13cm; box-sizing:border-box; padding:.8cm 1cm; font-family:'Helvetica','Arial',sans-serif; overflow:hidden; }
< / style >
< link rel = "stylesheet" href = "https://uicdn.toast.com/editor/latest/toastui-editor.min.css" / >
< script src = "https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js" > < / script >
2026-03-08 10:12:32 +01:00
< script src = "https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js" > < / script >
2026-03-08 10:35:29 +01:00
< script src = "https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js" > < / script >
< script src = "https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js" > < / script >
2026-03-08 07:36:37 +01:00
< / head > < body >
< div class = "topbar" >
< button id = "menuToggle" class = "menu-btn" > ☰< / button >
< div class = "brand" > < span class = "emoji" > 🗂️< / span > < span > Coachingcards< / span > < / div >
< div style = "margin-left:auto;display:flex;align-items:center;gap:8px;font-size:13px" > < label for = "showTestOnly" > Testcards< / label > < input id = "showTestOnly" type = "checkbox" > < / div >
< / div >
< div class = "layout" >
< aside id = "sidebar" class = "sidebar" >
<!-- Import menu hidden by request -->
< button id = "openReorder" > ↕️ Reihenfolge anpassen< / button >
< button id = "openAi" > 🤖 KI-Generierung< / button >
2026-03-08 10:09:20 +01:00
< button id = "downloadPdf" > 📄 PDF Download< / button >
2026-03-08 07:36:37 +01:00
< / aside >
< main class = "main" > < div id = "app" > < / div > < / main >
< / div >
< div id = "split-probe" > < / div >
< div id = "modal" > < div class = "box" > < h3 id = "m-title" > Text bearbeiten< / h3 > < textarea id = "editor" > < / textarea > < br > < button id = "save" > Speichern< / button > < button id = "cancel" > Abbrechen< / button > < / div > < / div >
< div id = "modalAll" > < div class = "box" > < div class = "modal-header" > < h3 id = "modalAllTitle" > Karte bearbeiten< / h3 > < button id = "closeAllX" class = "modal-close" title = "Schließen" > × < / button > < / div > < div class = "modal-body" > < div id = "allForm" class = "form-grid" > < / div > < div class = "modal-actions" > < button id = "cancelAll" class = "btn btn-secondary" > Abbrechen< / button > < button id = "saveAll" class = "btn btn-primary" > Alles speichern< / button > < / div > < / div > < / div > < / div >
< div id = "modalReorder" style = "position:fixed;inset:0;background:rgba(15,23,42,.45);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;z-index:70;padding:20px;" >
< div class = "box" style = "width:min(760px,96vw);max-height:90vh;overflow:auto;background:#fff;border-radius:14px;border:1px solid #dbe7ff;box-shadow:0 20px 60px rgba(15,23,42,.25)" >
< div class = "modal-header" > < h3 > Reihenfolge der Titel< / h3 > < button id = "closeReorderX" class = "modal-close" > × < / button > < / div >
< div class = "modal-body" >
< p style = "margin-top:0;color:#475569" > Per Drag & Drop verschieben.< / p >
< ul id = "reorderList" style = "list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:8px" > < / ul >
< div class = "modal-actions" > < button id = "cancelReorder" class = "btn btn-secondary" > Abbrechen< / button > < button id = "saveReorder" class = "btn btn-primary" > Reihenfolge speichern< / button > < / div >
< / div >
< / div >
< / div >
< div id = "modalAi" style = "position:fixed;inset:0;background:rgba(15,23,42,.45);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;z-index:75;padding:20px;" >
< div class = "box" style = "width:min(1320px,98vw);max-height:96vh;overflow:auto;background:#fff;border-radius:14px;border:1px solid #dbe7ff;box-shadow:0 20px 60px rgba(15,23,42,.25)" >
< div class = "modal-header" style = "align-items:flex-start;gap:14px;" >
< div style = "flex:1;min-width:260px;" >
< h3 style = "margin-bottom:8px;" > KI-Generierung< / h3 >
< div style = "height:10px;background:#e2e8f0;border-radius:99px;overflow:hidden" > < div id = "aiProgressBar" style = "height:100%;width:0%;background:#2563eb" > < / div > < / div >
< small id = "aiProgressText" style = "color:#475569" > Bereit< / small >
< / div >
< div style = "display:flex;align-items:center;gap:8px;" >
< button id = "cancelAi" class = "btn btn-secondary" > Schließen< / button >
< button id = "abortAi" class = "btn btn-secondary" style = "display:none" > Abbrechen< / button >
< button id = "runAi" class = "btn btn-primary" > < span id = "runAiLabel" > Generieren< / span > < span id = "runAiSpinner" class = "ai-spin" style = "display:none" > < / span > < / button >
< button id = "closeAiX" class = "modal-close" > × < / button >
< / div >
< / div >
< div class = "modal-body" >
< div style = "display:grid;grid-template-columns:minmax(320px,1fr) minmax(520px,1.5fr);gap:16px;align-items:start;" >
< div style = "display:flex;flex-direction:column;min-height:0;" >
< div style = "display:flex;align-items:center;justify-content:space-between;gap:8px;" >
< b > Karten auswählen< / b >
< div style = "display:flex;gap:6px;" >
< button id = "aiSelectAll" type = "button" class = "btn btn-secondary" style = "padding:6px 10px;" > Alle auswählen< / button >
< button id = "aiSelectNone" type = "button" class = "btn btn-secondary" style = "padding:6px 10px;" > Keine auswählen< / button >
< / div >
< / div >
< div id = "aiCardPick" style = "flex:1 1 auto;min-height:420px;height:calc(96vh - 290px);overflow:auto;border:1px solid #e2e8f0;border-radius:10px;padding:8px;margin-top:8px" > < / div >
< / div >
< div >
< div style = "display:flex;align-items:center;justify-content:space-between;gap:10px" >
< b > Prompt (editierbar)< / b >
< select id = "promptVersionSelect" style = "max-width:320px" >
< option value = "" > Version wählen…< / option >
< / select >
< / div >
< div class = "wysiwyg-wrap" > < div id = "aiPromptEditor" > < / div > < / div >
< b style = "display:block;margin-top:10px" > Fixer Bereich (nicht editierbar)< / b >
< textarea id = "aiPromptLocked" style = "width:100%;min-height:180px;margin-top:8px" readonly > < / textarea >
< / div >
< / div >
< / div >
< / div >
< / div >
< script >
let currentEdit=null;
let currentEditAllId=null;
let currentEditAllMode='input';
let currentItems=[];
let showTestOnly = false;
const app=document.getElementById('app');
const sidebar=document.getElementById('sidebar');
const modal=document.getElementById('modal');
const modalAll=document.getElementById('modalAll');
const modalReorder=document.getElementById('modalReorder');
const modalAi=document.getElementById('modalAi');
const reorderList=document.getElementById('reorderList');
const editor=document.getElementById('editor');
const allForm=document.getElementById('allForm');
const showTestOnlyEl=document.getElementById('showTestOnly');
let aiActiveJobId = null;
let aiPolling = false;
let promptEditor = null;
let aiJobClientStartTs = null;
let aiEtaBaseSec = null;
let aiEtaBaseTs = null;
let aiLastDone = 0;
function fmtDuration(seconds){
const s = Math.max(0, Math.round(seconds||0));
const m = Math.floor(s/60);
const r = s%60;
if (m< =0) return `${r}s`;
return `${m}m ${r}s`;
}
function setAiControlsRunning(running){
const runBtn = document.getElementById('runAi');
const runLbl = document.getElementById('runAiLabel');
const runSpin = document.getElementById('runAiSpinner');
const abortBtn = document.getElementById('abortAi');
runBtn.disabled = running;
runLbl.textContent = running ? 'Generiert…' : 'Generieren';
runSpin.style.display = running ? 'inline-block' : 'none';
abortBtn.style.display = running ? 'inline-block' : 'none';
}
function esc(s){return String(s??'').replaceAll('&','& ').replaceAll('< ','< ');}
function setupMarkdownEditor(){
if (promptEditor) return;
const host = document.getElementById('aiPromptEditor');
if (!host || !window.toastui?.Editor) return;
promptEditor = new toastui.Editor({
el: host,
initialEditType: 'wysiwyg',
previewStyle: 'vertical',
height: '520px',
hideModeSwitch: false,
usageStatistics: false,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task'],
['table', 'link'],
['code', 'codeblock']
]
});
}
function getPromptText(){
if (!promptEditor) return '';
return promptEditor.getMarkdown();
}
function setPromptText(v){
if (!promptEditor) return;
promptEditor.setMarkdown(String(v || ''));
}
function toStepParagraphs(text){
const t = String(text || '');
if (!t.trim()) return '';
if (t.includes('< ol > ') || t.includes('< ul > ') || t.includes('< li > ')) {
const isOrdered = t.includes('< ol > ');
let counter = 0;
return t
.replace(/< ol > |< ul > /gi, '')
.replace(/< \/ol>|< \/ul>/gi, '')
.replace(/< li > /gi, () => isOrdered ? `< p class = "step-item" > ${++counter}. ` : '< p class = "step-item" > ')
.replace(/< \/li>/gi, '< / p > ');
}
return t
.split(/\n+/)
.map((l) => l.trim())
.filter((l) => l.length > 0)
.map((l) => `< p class = "step-item" > ${esc(l)}< / p > `)
.join('');
}
function splitParagraphs(rawInhalt) {
const raw = String(rawInhalt || '').trim();
if (!raw) return [];
return raw
.split(/\n+/)
.map((p) => p.trim())
.filter((p) => p.length > 0);
}
function layoutLangtexts() {
const probe = document.getElementById('split-probe');
if (!probe) return;
const buildHTML = (paras) => paras.map((p) => `< p class = "step-item" > ${esc(p)}< / p > `).join('');
document.querySelectorAll('.langtext-inhalt-seite1[data-fulltext]').forEach((el1) => {
const id = el1.getAttribute('data-card-id');
const el2 = document.getElementById(`inhalt2_${id}`);
if (!el2) return;
const rawText = decodeURIComponent(el1.getAttribute('data-fulltext') || '');
const paragraphs = splitParagraphs(rawText);
const src = paragraphs.length
? paragraphs
: String(rawText).split(/(?< =[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
const probeInner = document.createElement('div');
const targetHeight = Math.max(80, el1.clientHeight || 260);
probeInner.style.cssText = [
'column-count:2',
'column-gap:30px',
'column-fill:auto',
`height:${targetHeight}px`,
'overflow:hidden',
'width:100%',
'box-sizing:border-box',
'font-family:Helvetica,Arial,sans-serif',
'font-size:10.5pt',
'line-height:1.4',
'text-align:left'
].join(';');
probe.replaceChildren(probeInner);
const isOverflowing = () => probeInner.scrollHeight > probeInner.clientHeight || probeInner.scrollWidth > probeInner.clientWidth;
const fitsParas = [];
let spillParas = [];
for (let i = 0; i < src.length ; i + + ) {
fitsParas.push(src[i]);
probeInner.innerHTML = buildHTML(fitsParas);
if (isOverflowing()) {
// Nur ganze Absätze: letzten zurücknehmen und vollständig auf Seite 2 schieben
fitsParas.pop();
spillParas = src.slice(fitsParas.length);
break;
}
if (i === src.length - 1) spillParas = [];
}
el1.innerHTML = buildHTML(fitsParas);
el2.innerHTML = buildHTML(spillParas);
});
}
function imageHtml(item, slot, cls, label){const v=item.output.images?.[slot];return `< div class = "${cls}" data-image-slot = "${slot}" data-id = "${item.id}" > ${v?`< img src = "${v}" / > `:label}< / div > `}
function textNode(item, field, cls=''){return `< span class = "editable ${cls}" data-id = "${item.id}" data-field = "${field}" > ${esc(item.output[field]||'')}< / span > `}
function actionBtns(item){
return `< div class = "card-actions" >
< button class = "card-action-btn input" title = "Input-Felder bearbeiten" data-edit-all = "${item.id}" data-edit-mode = "input" > 📝< / button >
< button class = "card-action-btn output" title = "Output-Felder bearbeiten" data-edit-all = "${item.id}" data-edit-mode = "output" > 📤< / button >
< / div > `;
}
function getItem(id){ return currentItems.find((x)=>String(x.id)===String(id)); }
function getRawField(id, field){
const item = getItem(id);
if(!item) return '';
return item.output?.[field] ?? '';
}
async function load(){
const r=await fetch('/api/cards'); const j=await r.json();
currentItems = Array.isArray(j.items) ? j.items : [];
let html='';
for(const item of currentItems){
if (showTestOnly & & !item.selected_for_test) continue;
const data=item.output; const isLang=data.langtext===true||data.langtext==='true';
if(data.oberkapitel===true||data.oberkapitel==='true'){
html+=`< div class = "card-size chapter-card" > ${actionBtns(item)}< h1 > ${textNode(item,'titel')}< / h1 > < p > ${textNode(item,'Beschreibung')}< / p > < p > ${textNode(item,'zitat')}< / p > < p > ${textNode(item,'Author')}< / p > < / div > `;
} else {
html+=`< div class = "exercise-container" > `;
if(isLang){
const cardId = `lt_${item.id}`;
const encoded = encodeURIComponent(String(data.inhalt || ''));
html+=`< div class = "card-size" > ${actionBtns(item)}< div class = "header-row" > < h2 class = "title" > ${textNode(item,'titel')}< / h2 > < span class = "duration" > ⏱️ ${textNode(item,'Dauer')}< / span > < / div >
< div class = "langtext-seite1-layout" > < div class = "langtext-top-band" > ${imageHtml(item,'main','image-placeholder-main','Haupt-Illustration')}< div class = "short-desc" > ${textNode(item,'kurzbeschreibung')}< / div > < / div > < div class = "langtext-inhalt-seite1 editable" data-id = "${item.id}" data-field = "inhalt" data-card-id = "${cardId}" data-fulltext = "${encoded}" id = "inhalt1_${cardId}" > < / div > < / div > < / div > `;
html+=`< div class = "card-size" > ${actionBtns(item)}< div class = "header-row" > < h2 class = "title" > ${textNode(item,'titel')}< / h2 > < / div > < div class = "columns-wrapper editable" data-id = "${item.id}" data-field = "inhalt" id = "inhalt2_${cardId}" > < / div > < / div > < / div > `;
} else {
html+=`< div class = "card-size" > ${actionBtns(item)}< div class = "header-row" > < h2 class = "title" > ${textNode(item,'titel')}< / h2 > < span class = "duration" > ⏱️ ${textNode(item,'Dauer')}< / span > < / div > < div style = "display:flex;gap:15px;flex-grow:1;overflow:hidden;" > ${imageHtml(item,'main','image-placeholder-normal','Haupt-Illustration')}< div class = "short-desc" > ${textNode(item,'kurzbeschreibung')}< / div > < / div > < / div > `;
html+=`< div class = "card-size" > < div class = "header-row" > < h2 class = "title" > ${textNode(item,'titel')}< / h2 > < / div > < div class = "columns-wrapper editable" data-id = "${item.id}" data-field = "inhalt" > ${toStepParagraphs(data.inhalt||'')}${imageHtml(item,'detail','image-placeholder-small','Detail-Skizze')}< / div > ${data.Tip?`< div class = "tip-box-full" > < span class = "tip-icon" > 💡< / span > < div class = "editable" data-id = "${item.id}" data-field = "Tip" > ${esc(data.Tip)}< / div > < / div > `:''}< / div > < / div > `;
}
}
}
app.innerHTML=html;
layoutLangtexts();
bind();
}
function fieldInput(name, value=''){return `< input data-field = "${name}" data-type = "text" value = "${esc(String(value ?? ''))}" > `;}
function fieldNumber(name, value=''){return `< input type = "number" data-field = "${name}" data-type = "number" value = "${esc(String(value ?? ''))}" > `;}
function fieldCheckbox(name, value=false){return `< div class = "check-wrap" > < input type = "checkbox" data-field = "${name}" data-type = "checkbox" $ { value ? ' checked ' : ' ' } > < / div > `;}
function fieldTextarea(name, value='', rows=2, compact=false){return `< textarea rows = "${rows}" data-field = "${name}" data-type = "text" class = "${compact?'compact':''}" > ${esc(String(value ?? ''))}< / textarea > `;}
function renderAllFieldsForm(id, mode='input'){
const item = getItem(id);
if(!item) return;
const src = structuredClone((mode === 'output' ? item.output : item.input) || item.output || {});
const titleEl = document.getElementById('modalAllTitle');
if (titleEl) titleEl.textContent = mode === 'output' ? 'Output-Felder bearbeiten' : 'Input-Felder bearbeiten';
allForm.innerHTML = `
< div class = "form-row two" >
< div class = "field" > < label > Titel< / label > ${fieldInput('titel', src.titel || '')}< / div >
< div class = "field" > < label > Sort Order< / label > ${fieldNumber('__sort_order__', item.sort_order ?? '')}< / div >
< / div >
< div class = "form-row" >
< div class = "field" > < label > Dauer< / label > ${fieldInput('Dauer', src.Dauer || '')}< / div >
< div class = "field" > < label > Oberkapitel< / label > ${fieldCheckbox('oberkapitel', src.oberkapitel === true || src.oberkapitel === 'true')}< / div >
< div class = "field" > < label > Langtext< / label > ${fieldCheckbox('langtext', src.langtext === true || src.langtext === 'true')}< / div >
< div class = "field" > < / div >
< / div >
< div class = "form-row two" >
< div class = "field" > < label > Zitat< / label > ${fieldInput('zitat', src.zitat || '')}< / div >
< div class = "field" > < label > Author< / label > ${fieldInput('Author', src.Author || '')}< / div >
< / div >
< div class = "field compact" > < label > Kurztext< / label > ${fieldTextarea('kurzbeschreibung', src.kurzbeschreibung || '', 2, true)}< / div >
< div class = "field" > < label > Beschreibung< / label > ${fieldTextarea('Beschreibung', src.Beschreibung || '', 4)}< / div >
< div class = "field" > < label > Inhalt< / label > ${fieldTextarea('inhalt', src.inhalt || '', 4)}< / div >
`;
}
function collectAllFields(){
const out = {};
let sortOrder = null;
allForm.querySelectorAll('[data-field]').forEach((el)=>{
const k = el.getAttribute('data-field');
const t = el.getAttribute('data-type');
if (k === '__sort_order__') {
const n = Number(el.value);
sortOrder = Number.isFinite(n) ? n : null;
return;
}
if (t === 'checkbox') {
out[k] = Boolean(el.checked);
return;
}
out[k] = el.value;
});
return { input: out, sort_order: sortOrder };
}
function openReorderModal(){
reorderList.innerHTML='';
const items = [...currentItems].sort((a,b)=>(a.sort_order||0)-(b.sort_order||0));
items.forEach((it)=>{
const li=document.createElement('li');
li.draggable=true;
li.dataset.id=String(it.id);
li.style.cssText='padding:10px 12px;border:1px solid #cbd5e1;border-radius:10px;background:#fff;cursor:grab;display:flex;justify-content:space-between;gap:8px';
li.innerHTML=`< span > ${esc(it.output?.titel||'(ohne Titel)')}< / span > < small style = "color:#64748b" > #${it.sort_order||''}< / small > `;
reorderList.appendChild(li);
});
let dragEl=null;
reorderList.querySelectorAll('li').forEach((li)=>{
li.addEventListener('dragstart',()=>{dragEl=li; li.style.opacity='.5';});
li.addEventListener('dragend',()=>{li.style.opacity='1';});
li.addEventListener('dragover',(e)=>{e.preventDefault();});
li.addEventListener('drop',(e)=>{
e.preventDefault();
if(!dragEl||dragEl===li) return;
const rect = li.getBoundingClientRect();
const after = (e.clientY - rect.top) > rect.height/2;
if(after) li.after(dragEl); else li.before(dragEl);
});
});
modalReorder.style.display='flex';
document.body.classList.add('modal-open');
}
function closeReorderModal(){
modalReorder.style.display='none';
document.body.classList.remove('modal-open');
}
async function openAiModal(){
setupMarkdownEditor();
const cardsWrap = document.getElementById('aiCardPick');
const locked = document.getElementById('aiPromptLocked');
const versionSelect = document.getElementById('promptVersionSelect');
const r = await fetch('/api/prompt/current');
const j = await r.json();
setPromptText(j.prompt_editable || '');
locked.value = j.prompt_locked || '';
const vr = await fetch('/api/prompt/versions');
const vj = await vr.json();
const versions = Array.isArray(vj?.items) ? vj.items : [];
versionSelect.innerHTML = '< option value = "" > Version wählen…< / option > ';
versions.forEach((v)=>{
const opt = document.createElement('option');
opt.value = String(v.id);
const dt = v.created_at ? new Date(v.created_at).toLocaleString() : '';
opt.textContent = `#${v.id}${dt ? ' – ' + dt : ''}`;
opt.dataset.prompt = v.prompt_editable || '';
versionSelect.appendChild(opt);
});
versionSelect.onchange = ()=>{
const selected = versionSelect.selectedOptions?.[0];
if (!selected || !selected.dataset.prompt) return;
setPromptText(selected.dataset.prompt);
};
cardsWrap.innerHTML = '';
currentItems.forEach((it)=>{
const row = document.createElement('label');
row.style.display='flex'; row.style.gap='8px'; row.style.alignItems='center'; row.style.padding='4px 0';
row.innerHTML = `< input type = "checkbox" data-ai-id = "${it.id}" $ { it . selected_for_test ? ' checked ' : ' ' } > < span > ${esc(it.output?.titel||'(ohne Titel)')}< / span > `;
cardsWrap.appendChild(row);
});
document.getElementById('aiSelectAll').onclick=()=>{ cardsWrap.querySelectorAll('[data-ai-id]').forEach(cb=>cb.checked=true); };
document.getElementById('aiSelectNone').onclick=()=>{ cardsWrap.querySelectorAll('[data-ai-id]').forEach(cb=>cb.checked=false); };
document.getElementById('aiProgressBar').style.width='0%';
document.getElementById('aiProgressText').textContent='Bereit';
const active = await syncActiveJob();
if (active) {
if (!aiJobClientStartTs) aiJobClientStartTs = Date.now();
document.getElementById('aiProgressBar').style.width = `${active.progress||0}%`;
document.getElementById('aiProgressText').textContent = `${active.done||0}/${active.total||0} (${active.progress||0}%)`;
setAiControlsRunning(true);
if (!aiPolling) pollJob(active.id);
} else {
setAiControlsRunning(false);
}
modalAi.style.display='flex';
document.body.classList.add('modal-open');
}
function closeAiModal(){
modalAi.style.display='none';
document.body.classList.remove('modal-open');
}
2026-03-08 10:12:32 +01:00
async function downloadPdf(){
2026-03-08 10:59:10 +01:00
console.log('[PDF] click');
2026-03-08 10:55:32 +01:00
const openPrintFallback = (msg='') => {
2026-03-08 10:59:10 +01:00
console.warn('[PDF] fallback print', msg || '(no message)');
2026-03-08 10:55:32 +01:00
if (msg) alert(msg);
2026-03-08 10:56:50 +01:00
const w = window.open('', '_blank');
2026-03-08 10:59:10 +01:00
if (!w) {
console.error('[PDF] fallback window blocked');
return;
}
2026-03-08 10:56:50 +01:00
w.document.write(`<!doctype html> < html > < head > < meta charset = "utf-8" > < title > Coachingcards PDF< / title > < / head > < body > ${app.innerHTML}< / body > < / html > `);
w.document.close();
w.onload = () => setTimeout(() => w.print(), 200);
2026-03-08 10:55:32 +01:00
};
2026-03-08 10:59:10 +01:00
console.log('[PDF] libs', {
html2canvas: !!window.html2canvas,
jspdf: !!(window.jspdf & & window.jspdf.jsPDF)
});
2026-03-08 10:35:29 +01:00
if (!window.html2canvas || !window.jspdf?.jsPDF) {
2026-03-08 10:55:32 +01:00
openPrintFallback('PDF-Bibliotheken fehlen – Druckdialog als Fallback.');
2026-03-08 10:12:32 +01:00
return;
}
2026-03-08 10:32:58 +01:00
2026-03-08 10:12:32 +01:00
const root = document.createElement('div');
2026-03-08 10:32:58 +01:00
root.style.position = 'fixed';
2026-03-08 10:35:29 +01:00
root.style.left = '-10000px';
2026-03-08 10:32:58 +01:00
root.style.top = '0';
2026-03-08 10:35:29 +01:00
root.style.width = '1200px';
2026-03-08 10:12:32 +01:00
root.style.background = '#fff';
root.innerHTML = app.innerHTML;
root.querySelectorAll('.card-actions,.card-action-btn,button,[data-edit-all]').forEach(el => el.remove());
2026-03-08 10:55:32 +01:00
root.querySelectorAll('.card-size').forEach(el => { el.style.boxShadow = 'none'; el.style.margin = '0 0 12px 0'; });
2026-03-08 10:32:58 +01:00
document.body.appendChild(root);
2026-03-08 10:12:32 +01:00
2026-03-08 10:32:58 +01:00
try {
2026-03-08 10:35:29 +01:00
const cards = [...root.querySelectorAll('.card-size')];
2026-03-08 10:59:10 +01:00
console.log('[PDF] cards found', cards.length);
2026-03-08 10:55:32 +01:00
if (!cards.length) throw new Error('Keine Karten sichtbar');
2026-03-08 10:35:29 +01:00
const { jsPDF } = window.jspdf;
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageW = pdf.internal.pageSize.getWidth();
const pageH = pdf.internal.pageSize.getHeight();
const margin = 8;
const usableW = pageW - margin * 2;
const usableH = pageH - margin * 2;
for (let i = 0; i < cards.length ; i + + ) {
2026-03-08 10:59:10 +01:00
console.log('[PDF] rendering card', i + 1, '/', cards.length);
2026-03-08 10:55:32 +01:00
const canvas = await Promise.race([
window.html2canvas(cards[i], { scale: 2, useCORS: true, backgroundColor: '#ffffff', logging: false }),
new Promise((_, rej) => setTimeout(() => rej(new Error('Render timeout')), 15000))
]);
2026-03-08 10:35:29 +01:00
const img = canvas.toDataURL('image/jpeg', 0.98);
2026-03-08 10:55:32 +01:00
const ratio = Math.min(usableW / canvas.width, usableH / canvas.height);
const drawW = canvas.width * ratio;
const drawH = canvas.height * ratio;
2026-03-08 10:35:29 +01:00
const x = margin + (usableW - drawW) / 2;
const y = margin + (usableH - drawH) / 2;
if (i > 0) pdf.addPage();
pdf.addImage(img, 'JPEG', x, y, drawW, drawH, undefined, 'FAST');
}
2026-03-08 10:59:10 +01:00
const filename = `coachingcards-${new Date().toISOString().slice(0,10)}.pdf`;
console.log('[PDF] saving', filename);
pdf.save(filename);
console.log('[PDF] save triggered');
2026-03-08 10:55:32 +01:00
} catch (e) {
2026-03-08 10:59:10 +01:00
console.error('[PDF] export failed', e);
openPrintFallback('PDF-Download fehlgeschlagen – nutze Druckdialog als Fallback. Schau in die Browser-Konsole.');
2026-03-08 10:32:58 +01:00
} finally {
root.remove();
2026-03-08 10:59:10 +01:00
console.log('[PDF] cleanup done');
2026-03-08 10:32:58 +01:00
}
2026-03-08 10:09:20 +01:00
}
2026-03-08 07:36:37 +01:00
async function syncActiveJob(){
const r = await fetch('/api/generate/active');
const j = await r.json();
aiActiveJobId = j?.active?.id || null;
return j?.active || null;
}
async function pollJob(jobId){
aiPolling = true;
aiActiveJobId = jobId;
if (!aiJobClientStartTs) {
const savedStart = Number(localStorage.getItem('aiJobStartTs') || '0');
aiJobClientStartTs = savedStart > 0 ? savedStart : Date.now();
}
localStorage.setItem('aiActiveJobId', jobId);
localStorage.setItem('aiJobStartTs', String(aiJobClientStartTs));
while(true){
const r = await fetch(`/api/generate/job/${jobId}`);
const j = await r.json();
if(!j.ok){ document.getElementById('aiProgressText').textContent = j.error || 'Fehler'; setAiControlsRunning(false); aiPolling=false; return j; }
document.getElementById('aiProgressBar').style.width = `${j.progress||0}%`;
let etaTxt = '';
const now = Date.now();
const done = Number(j.done||0);
const total = Number(j.total||0);
if (done !== aiLastDone) {
aiLastDone = done;
if (done >= 1 & & total > done & & aiJobClientStartTs) {
const elapsedSec = (now - aiJobClientStartTs) / 1000;
const avgPerCard = elapsedSec / done;
aiEtaBaseSec = (total - done) * avgPerCard;
aiEtaBaseTs = now;
2026-03-08 08:34:26 +01:00
} else if (done === 0 & & total > 0) {
aiEtaBaseSec = total * 30;
aiEtaBaseTs = aiJobClientStartTs || now;
2026-03-08 07:36:37 +01:00
} else {
aiEtaBaseSec = null;
aiEtaBaseTs = null;
}
}
2026-03-08 08:34:26 +01:00
if ((aiEtaBaseSec == null || aiEtaBaseTs == null) & & done === 0 & & total > 0) {
aiEtaBaseSec = total * 30;
aiEtaBaseTs = aiJobClientStartTs || now;
}
2026-03-08 07:36:37 +01:00
if (aiEtaBaseSec != null & & aiEtaBaseTs != null & & total > done) {
const countdown = Math.max(0, aiEtaBaseSec - ((now - aiEtaBaseTs) / 1000));
2026-03-08 08:34:26 +01:00
etaTxt = countdown < 10 ? ' – gleich fertig ' : ` – Restdauer ~ $ { fmtDuration ( countdown ) } ` ;
2026-03-08 07:36:37 +01:00
}
document.getElementById('aiProgressText').textContent = `${done}/${total} (${j.progress||0}%)${etaTxt}`;
const running = ['running','cancelling'].includes(j.status);
setAiControlsRunning(running);
if (!running) {
aiPolling = false;
aiActiveJobId = null;
aiJobClientStartTs = null;
aiEtaBaseSec = null;
aiEtaBaseTs = null;
aiLastDone = 0;
localStorage.removeItem('aiActiveJobId');
localStorage.removeItem('aiJobStartTs');
if ((j.errors||[]).length) document.getElementById('aiProgressText').textContent += ` – ${j.errors.length} Fehler`;
if (j.status === 'aborted') document.getElementById('aiProgressText').textContent = 'Abgebrochen';
return j;
}
await new Promise(res=>setTimeout(res, 1000));
}
}
function bind(){
document.querySelectorAll('.editable').forEach(el=>el.onclick=(ev)=>{
if (ev.target & & ev.target.dataset & & ev.target.dataset.imageSlot) return;
currentEdit={id:el.dataset.id,field:el.dataset.field};
const raw = getRawField(currentEdit.id, currentEdit.field);
editor.value = String(raw ?? '').replace(/\r\n/g,'\n');
modal.style.display='flex';
document.body.classList.add('modal-open');
});
document.querySelectorAll('[data-image-slot]').forEach(el=>el.onclick=(ev)=>{ev.stopPropagation(); uploadImage(el.dataset.id,el.dataset.imageSlot)});
document.querySelectorAll('[data-edit-all]').forEach(btn=>btn.onclick=(ev)=>{
ev.stopPropagation();
currentEditAllId = btn.getAttribute('data-edit-all');
currentEditAllMode = btn.getAttribute('data-edit-mode') || 'input';
renderAllFieldsForm(currentEditAllId, currentEditAllMode);
modalAll.style.display='flex';
document.body.classList.add('modal-open');
});
}
async function uploadImage(id,slot){const inp=document.createElement('input'); inp.type='file'; inp.accept='image/*'; inp.onchange=async()=>{if(!inp.files[0])return; const fd=new FormData(); fd.append('file',inp.files[0]); await fetch(`/api/cards/${id}/image/${slot}`,{method:'POST',body:fd}); load();}; inp.click();}
document.getElementById('cancel').onclick=()=>{ modal.style.display='none'; document.body.classList.remove('modal-open'); };
document.getElementById('save').onclick=async()=>{
if(!currentEdit)return;
const value = String(editor.value||'').replace(/\r\n/g,'\n');
await fetch(`/api/cards/${currentEdit.id}/field`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({field:currentEdit.field,value})});
modal.style.display='none';
document.body.classList.remove('modal-open');
load();
};
function closeAllModal(){ modalAll.style.display='none'; currentEditAllId=null; currentEditAllMode='input'; document.body.classList.remove('modal-open'); }
document.getElementById('cancelAll').onclick=closeAllModal;
document.getElementById('closeAllX').onclick=closeAllModal;
document.getElementById('saveAll').onclick=async()=>{
if(!currentEditAllId) return;
const payload = collectAllFields();
const endpoint = currentEditAllMode === 'output' ? 'output' : 'input';
const body = currentEditAllMode === 'output'
? { output: payload.input, sort_order: payload.sort_order }
: payload;
await fetch(`/api/cards/${currentEditAllId}/${endpoint}`, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body)
});
closeAllModal();
load();
};
document.getElementById('menuToggle').onclick=()=>sidebar.classList.toggle('open');
document.getElementById('openReorder').onclick=()=>{ sidebar.classList.remove('open'); openReorderModal(); };
document.getElementById('openAi').onclick=async()=>{ sidebar.classList.remove('open'); await openAiModal(); };
2026-03-08 10:09:20 +01:00
document.getElementById('downloadPdf').onclick=()=>{ sidebar.classList.remove('open'); downloadPdf(); };
2026-03-08 07:36:37 +01:00
document.getElementById('cancelReorder').onclick=closeReorderModal;
document.getElementById('closeReorderX').onclick=closeReorderModal;
document.getElementById('saveReorder').onclick=async()=>{
const order=[...reorderList.querySelectorAll('li')].map(li=>Number(li.dataset.id)).filter(Number.isFinite);
await fetch('/api/reorder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({order})});
closeReorderModal();
load();
};
showTestOnlyEl.addEventListener('change',()=>{ showTestOnly = Boolean(showTestOnlyEl.checked); load(); });
document.getElementById('cancelAi').onclick=closeAiModal;
document.getElementById('closeAiX').onclick=closeAiModal;
document.getElementById('abortAi').onclick=async()=>{
if (!aiActiveJobId) return;
await fetch(`/api/generate/job/${aiActiveJobId}/cancel`, { method:'POST' });
document.getElementById('aiProgressText').textContent = 'Abbrechen angefordert…';
};
document.getElementById('runAi').onclick=async()=>{
if (aiActiveJobId) { document.getElementById('aiProgressText').textContent = 'Es läuft bereits eine Generierung.'; return; }
const ids = [...document.querySelectorAll('[data-ai-id]')].filter(x=>x.checked).map(x=>Number(x.getAttribute('data-ai-id'))).filter(Number.isFinite);
await fetch('/api/cards/test-selection',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids})});
const prompt_editable = getPromptText();
aiJobClientStartTs = Date.now();
localStorage.setItem('aiJobStartTs', String(aiJobClientStartTs));
aiEtaBaseSec = null;
aiEtaBaseTs = null;
aiLastDone = 0;
setAiControlsRunning(true);
const r = await fetch('/api/generate/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids,prompt_editable})});
const j = await r.json();
if(!j.ok){
if (j.jobId) {
aiActiveJobId = j.jobId;
if (!aiPolling) pollJob(j.jobId);
} else {
setAiControlsRunning(false);
}
document.getElementById('aiProgressText').textContent = j.error || 'Fehler';
return;
}
const finalJob = await pollJob(j.jobId);
if (finalJob?.errors?.length) {
const first = finalJob.errors[0];
document.getElementById('aiProgressText').textContent = `Fehler: ${first.error || 'Unbekannt'}`;
return;
}
closeAiModal();
showTestOnlyEl.checked = true;
showTestOnly = true;
load();
};
load();
(async()=>{
const remembered = localStorage.getItem('aiActiveJobId');
const active = await syncActiveJob();
if (active?.id) {
aiActiveJobId = active.id;
if (!aiPolling) pollJob(active.id);
return;
}
if (remembered) {
aiActiveJobId = remembered;
if (!aiPolling) pollJob(remembered);
}
})();
< / script > < / body > < / html >