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

758 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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(){
const openPrintFallback = (msg='') => {
if (msg) alert(msg);
const w = window.open('', '_blank');
if (!w) 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);
};
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')];
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++) {
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');
}
pdf.save(`coachingcards-${new Date().toISOString().slice(0,10)}.pdf`);
} catch (e) {
console.error('PDF export failed', e);
openPrintFallback('PDF-Download fehlgeschlagen nutze Druckdialog als Fallback.');
} finally {
root.remove();
}
}
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>