Files
exercise-cards-app/public/index.html

773 lines
39 KiB
HTML
Raw Normal View History

<!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>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<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>
</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>
<button id="downloadPdf">📄 PDF Download</button>
</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('&','&amp;').replaceAll('<','&lt;');}
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');
}
async function downloadPdf(){
console.log('[PDF] click');
const openPrintFallback = (msg='') => {
console.warn('[PDF] fallback print', msg || '(no message)');
if (msg) alert(msg);
const w = window.open('', '_blank');
if (!w) {
console.error('[PDF] fallback window blocked');
return;
}
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);
};
console.log('[PDF] libs', {
html2canvas: !!window.html2canvas,
jspdf: !!(window.jspdf && window.jspdf.jsPDF)
});
if (!window.html2canvas || !window.jspdf?.jsPDF) {
openPrintFallback('PDF-Bibliotheken fehlen Druckdialog als Fallback.');
return;
}
const root = document.createElement('div');
root.style.position = 'fixed';
root.style.left = '-10000px';
root.style.top = '0';
root.style.width = '1200px';
root.style.background = '#fff';
root.innerHTML = app.innerHTML;
root.querySelectorAll('.card-actions,.card-action-btn,button,[data-edit-all]').forEach(el => el.remove());
root.querySelectorAll('.card-size').forEach(el => { el.style.boxShadow = 'none'; el.style.margin = '0 0 12px 0'; });
document.body.appendChild(root);
try {
const cards = [...root.querySelectorAll('.card-size')];
console.log('[PDF] cards found', cards.length);
if (!cards.length) throw new Error('Keine Karten sichtbar');
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++) {
console.log('[PDF] rendering card', i + 1, '/', cards.length);
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))
]);
const img = canvas.toDataURL('image/jpeg', 0.98);
const ratio = Math.min(usableW / canvas.width, usableH / canvas.height);
const drawW = canvas.width * ratio;
const drawH = canvas.height * ratio;
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');
}
const filename = `coachingcards-${new Date().toISOString().slice(0,10)}.pdf`;
console.log('[PDF] saving', filename);
pdf.save(filename);
console.log('[PDF] save triggered');
} catch (e) {
console.error('[PDF] export failed', e);
openPrintFallback('PDF-Download fehlgeschlagen nutze Druckdialog als Fallback. Schau in die Browser-Konsole.');
} finally {
root.remove();
console.log('[PDF] cleanup done');
}
}
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;
} else if (done === 0 && total > 0) {
aiEtaBaseSec = total * 30;
aiEtaBaseTs = aiJobClientStartTs || now;
} else {
aiEtaBaseSec = null;
aiEtaBaseTs = null;
}
}
if ((aiEtaBaseSec == null || aiEtaBaseTs == null) && done === 0 && total > 0) {
aiEtaBaseSec = total * 30;
aiEtaBaseTs = aiJobClientStartTs || now;
}
if (aiEtaBaseSec != null && aiEtaBaseTs != null && total > done) {
const countdown = Math.max(0, aiEtaBaseSec - ((now - aiEtaBaseTs) / 1000));
etaTxt = countdown < 10 ? ' gleich fertig' : ` Restdauer ~${fmtDuration(countdown)}`;
}
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(); };
document.getElementById('downloadPdf').onclick=()=>{ sidebar.classList.remove('open'); downloadPdf(); };
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>