feat: exercise cards webapp with AI workflow and editor
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
PORT=8097
|
||||||
|
DB_HOST=host.containers.internal
|
||||||
|
DB_PORT=5433
|
||||||
|
DB_NAME=openclaw
|
||||||
|
DB_USER=openclaw
|
||||||
|
DB_PASSWORD=3P7m!dQ9vL2xR8kN
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
9
Dockerfile
Normal file
9
Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
COPY src ./src
|
||||||
|
COPY public ./public
|
||||||
|
ENV PORT=8097
|
||||||
|
EXPOSE 8097
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
50
README.md
Normal file
50
README.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# exercise-cards-app
|
||||||
|
|
||||||
|
Webapp (zweiter Container), die auf die lokale Postgres-DB im ersten Container zugreift und Übungskarten formatiert rendert.
|
||||||
|
|
||||||
|
## Datenstruktur
|
||||||
|
|
||||||
|
In Tabelle `exercise_cards.output` (JSONB), inkl. Bildslots:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"oberkapitel": false,
|
||||||
|
"titel": "Rückenwelle",
|
||||||
|
"Dauer": "1–3 Minuten",
|
||||||
|
"kurzbeschreibung": "...",
|
||||||
|
"Tip": "...",
|
||||||
|
"langtext": false,
|
||||||
|
"inhalt": "...",
|
||||||
|
"images": {
|
||||||
|
"main": null,
|
||||||
|
"detail": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Ausgabe im Kartenlayout (A4/Print-kompatibel)
|
||||||
|
- Klick auf Text öffnet Modal zum Editieren
|
||||||
|
- Klick auf Bild-Platzhalter öffnet Upload (main/detail)
|
||||||
|
- Persistenz in Postgres
|
||||||
|
|
||||||
|
## Start (lokal)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t registry.aquantico.lan/exercise-cards-app:latest .
|
||||||
|
docker run --rm -p 8097:8097 \
|
||||||
|
-e DB_HOST=host.containers.internal \
|
||||||
|
-e DB_PORT=5433 \
|
||||||
|
-e DB_NAME=openclaw \
|
||||||
|
-e DB_USER=openclaw \
|
||||||
|
-e DB_PASSWORD='3P7m!dQ9vL2xR8kN' \
|
||||||
|
registry.aquantico.lan/exercise-cards-app:latest
|
||||||
|
```
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
exercise-cards-app:
|
||||||
|
image: registry.aquantico.lan/exercise-cards-app:latest
|
||||||
|
container_name: exercise-cards-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8097:8097"
|
||||||
|
environment:
|
||||||
|
- PORT=8097
|
||||||
|
- DB_HOST=host.containers.internal
|
||||||
|
- DB_PORT=5433
|
||||||
|
- DB_NAME=openclaw
|
||||||
|
- DB_USER=openclaw
|
||||||
|
- DB_PASSWORD=3P7m!dQ9vL2xR8kN
|
||||||
70
new-cards.json
Normal file
70
new-cards.json
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"output": {
|
||||||
|
"oberkapitel": true,
|
||||||
|
"titel": "Kapitel 1: Den Körper lockern und mobilisieren",
|
||||||
|
"zitat": "Mach dich locker.",
|
||||||
|
"Author": "Deutsche Redewendung",
|
||||||
|
"Beschreibung": "Stress, langes Sitzen, zu viel Bildschirmarbeit oder Bewegungsmangel führen häufig zu Muskelverspannungen. Mit den folgenden Übungen kannst du wieder beweglicher werden und gezielt Nackenverspannungen, Spannungskopfschmerz, verhärtete Schultern oder Rückenschmerzen lindern."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output": {
|
||||||
|
"oberkapitel": false,
|
||||||
|
"titel": "Rückenwelle",
|
||||||
|
"Dauer": "1–3 Minuten",
|
||||||
|
"kurzbeschreibung": "Dehnt die Rücken- und Bauchmuskulatur und mobilisiert die Wirbelsäule. Entspannt Schultern und Nacken und fördert eine aufrechte Haltung. Einfach am Stuhl oder im Stehen ausführbar, geeignet für alle Altersgruppen. Verbessert Durchblutung und Beweglichkeit bei regelmäßiger Praxis.",
|
||||||
|
"alter": "Für jedes Alter geeignet",
|
||||||
|
"Tip": "Führe die Bewegungen langsam und bewusst aus. Halte die Hände während der ganzen Übung auf den Oberschenkeln, so bleibt der Atem ruhig und die Kontrolle erhalten.",
|
||||||
|
"langtext": false,
|
||||||
|
"inhalt": "Setze dich fest auf einen Stuhl, ohne dich anzulehnen, oder stelle dich hin. Nimm die Füße hüftbreit auseinander und beuge die Knie locker. Lege die Hände auf den Oberschenkeln ab und atme entspannt. Für den Katzenbuckel spanne die Bauchmuskeln an, wölbe den Rücken rund, schiebe die Schultern nach vorne, strecke die Arme etwas und nimm das Kinn Richtung Brust. Für den Kuhrücken lasse die Beine leicht gebeugt, schiebe das Becken nach hinten, wölbe die Brust nach vorn, ziehe die Schulterblätter zusammen und hebe den Kopf leicht, ohne den Nacken zu überstrecken. Bewege die Wirbelsäule in ruhigem Tempo vom Katzenbuckel zum Kuhrücken und zurück. Verbinde Atem und Bewegung: Einatmend in den Kuhrücken, ausatmend in den Katzenbuckel. Wiederhole die Rückenwelle mehrmals täglich, um den Rücken zu entspannen und die Muskulatur gut zu durchbluten. Passe Tempo und Anzahl an dein Körpergefühl an."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output": {
|
||||||
|
"oberkapitel": false,
|
||||||
|
"titel": "Die Eule für den Nacken",
|
||||||
|
"Dauer": "5–10 Minuten",
|
||||||
|
"kurzbeschreibung": "Sanfte Übung gegen den typischen Handy‑Nacken, die Hals und Schultern dehnt. Fördert die Beweglichkeit und löst Verspannungen durch eine bewusste Handposition und leichte Kopfbewegung. Ideal als kurze Pause, gut kombinierbar mit Rückenwelle oder Augenyoga.",
|
||||||
|
"alter": "Für jedes Alter geeignet",
|
||||||
|
"Tip": "Atme ruhig und arbeite langsam; vermeide ruckartige Bewegungen. Nutze die Übung als kurze Auszeit im Alltag und kombiniere sie mit verwandten Übungen, um den Effekt zu verstärken.",
|
||||||
|
"langtext": false,
|
||||||
|
"inhalt": "Setze dich mit geradem Rücken auf einen Stuhl oder auf den Boden und spanne die Bauchmuskeln leicht an, damit kein Hohlkreuz entsteht. Stelle dir vor, dass deine rechte Hand eine Eule ist, die auf der linken Schulter landet. Lege die rechte Hand auf die linke Schulter und drücke diese mit sanftem Druck nach unten, während die linke Hand locker neben dem Bein hängt. Wenn die rechte Schulter von allein nach oben kommt, ziehe sie bewusst nach unten. Stelle dir vor, die Eule pickt in deine linke Wange und drehe deshalb den Kopf nach rechts, achte darauf, den Kopf nicht zu kippen. Lass den Atem ruhig fließen und nimm wahr, wie Hals und Nackenmuskulatur weicher werden. Löse die Position in Ruhe auf und wiederhole die Übung auf der anderen Seite, übe dabei achtsam und nicht ruckartig."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output": {
|
||||||
|
"oberkapitel": false,
|
||||||
|
"titel": "Schulternjojo",
|
||||||
|
"Dauer": "5-10 Minuten",
|
||||||
|
"kurzbeschreibung": "Kurze Lockerung für oberen Rücken und Schultern. Ideal zwischen Hausaufgaben oder während einer Gaming-Pause. Mit Schuhen oder Socken ausführbar, schnell entspannend. Hilft Verspannungen und steifem Nacken vorzubeugen. Für jedes Alter geeignet.",
|
||||||
|
"alter": "für jedes Alter",
|
||||||
|
"Tip": "Übe die Abfolge ruhig im Atemrhythmus und höre auf deinen Körper. Führe die Sequenz am Schreibtisch oder auf dem Sofa aus und wiederhole sie bei Bedarf. Kurze, regelmäßige Einheiten sind oft wirksamer als lange Sitzungen.",
|
||||||
|
"langtext": false,
|
||||||
|
"inhalt": "Setze dich auf einen Stuhl, stelle die Füße hüftbreit auf den Boden und lege die Hände auf die Knie. Richte deinen Oberkörper und den Kopf auf, so dass Rücken und Nacken gerade sind. Kreise beim Einatmen die Schultern nach hinten und beim Ausatmen nach vorne im Atemrhythmus. Wechsle die Richtung und kreise einige Male nach vorne, achte darauf, wie sich die Schulterblätter bewegen. Verschränke die Finger hinter dem Rücken, ziehe die geballten Fäuste beim Einatmen leicht nach unten und öffne das Brustbein. Beim Ausatmen runde den Rücken nach vorne, lasse den Kopf locker und strecke die verschränkten Arme Richtung Decke. Wiederhole die Abfolge so oft, bis sich der obere Rücken und die Schultern gelöst anfühlen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output": {
|
||||||
|
"oberkapitel": false,
|
||||||
|
"titel": "Kiefer gut, alles gut",
|
||||||
|
"Dauer": "5-10 Minuten",
|
||||||
|
"kurzbeschreibung": "Verspannungen zeigen sich häufig im verkrampften Kiefer und in angespannten Kaumuskeln. Mit diesen einfachen Übungen kannst du wieder loslassen und mehr Entspannung erreichen. Zähne zusammenbeißen war einmal — gib deinem Kiefer Raum zum Entspannen.",
|
||||||
|
"alter": "Für alle Altersgruppen",
|
||||||
|
"Tip": "Wenn die Massage schmerzt, nimm den Druck heraus und atme ruhig. Wärme die Hände vorher an und sei sanft mit dem Kiefer.",
|
||||||
|
"langtext": false,
|
||||||
|
"inhalt": "Setze dich bequem hin und schließe, wenn möglich, die Augen. Reibe die Hände kräftig, bis sie warm sind. Lege die warmen Hände auf die Kiefergelenke. Massiere den vorgewölbten Kaumuskel in kreisenden Bewegungen bis zu den oberen Wangenknochen. Wechsle stärkeres und leichteres Drücken und öffne den Mund ab und zu. Ertaste mit den Mittelfingern die Kuhle des Kiefergelenks neben den Ohrläppchen. Klopfe leicht mit den Fingerspitzen und drücke dann etwa 20 Sekunden sanft. Lockere den Kiefer, indem du ihn seitlich hin- und herschiebst. Dehne, indem du sehr langsam öffnest und bei offenem Mund kontrolliert zur Seite bewegst. Trommle sanft mit den Fingerspitzen, lege bei Wunsch die Hände noch einmal auf und spüre nach. Wenn es schmerzt, reduziere den Druck."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output": {
|
||||||
|
"oberkapitel": false,
|
||||||
|
"titel": "Bootsfahrt mit Delfinen",
|
||||||
|
"Dauer": "etwa 10 Minuten",
|
||||||
|
"kurzbeschreibung": "Eine kurze, geführte Entspannungsreise auf einem Boot mit Delfinen. Sie fördert Ruhe, Freude und ein Gefühl von Freiheit und Leichtigkeit. Ideal zum Abschalten und zur inneren Erfrischung. Dauer etwa zehn Minuten.",
|
||||||
|
"Tip": "Nimm dir einen ungestörten Moment und richte dich bequem ein. Atme ruhig und lass Bilder ohne Zwang zu; vertraue deinem Tempo.",
|
||||||
|
"langtext": true,
|
||||||
|
"inhalt": "Lass dich bequem nieder, im Sitzen oder Liegen, so dass dein Körper entspannt ist. Richte deine Aufmerksamkeit auf deinen Atem und spüre, wie sich dein Bauch beim Ein- und Ausatmen hebt und senkt. Stell dir vor, dein Bauch sei ein sanftes Meer mit leichten Wellen, und lege ein kleines Boot an deinen Bauchnabel. Sieh dich selbst in diesem Boot sitzen, die Sonne wärmt dich, ein leichter Wind streicht durchs Haar, und Möwen sind in der Ferne zu hören. Atme weiter ruhig und beobachte, wie das Boot harmonisch auf den Atemwellen gleitet. Weit draußen bemerkst du eine Bewegung: Eine Gruppe Delfine taucht auf und kommt näher, ihre Körper glänzen in der Sonne. Du beobachtest ihr spielerisches Springen und ihr elegantes Gleiten durch das Wasser und fühlst eine wachsende Freude. Die Delfine nähern sich deinem Boot, schwimmen neben dir und zeigen Vertrauen. Du entscheidest, ins warme Wasser zu gleiten; ein Delfin kommt dicht heran, du streichelst seinen Rücken und seine Flosse. Er nimmt dich sanft auf und zieht dich durchs Wasser, ihr gleitet zusammen, manchmal springt ihr aus dem Meer, und du spürst die Leichtigkeit des Moments. Das Wasser umspielt dein Gesicht und deinen Körper, die Sonne berührt deinen Rücken, und du fühlst dich sicher, frei und lebendig. Nach einer Weile bringt dich der Delfin zurück an dein Boot; du streichelst ihn noch einmal und verabschiedest dich mit einem freundlichen Blick. Die Delfine schwimmen weiter ins offene Meer, und du nimmst wieder Platz im Boot, das Gefühl von Zufriedenheit bleibt in dir. Lenke deine Aufmerksamkeit zurück auf deinen Atem, spüre das Heben und Senken des Bauchs. Atme tief ein und aus, beginne langsam, Finger und Zehen zu bewegen, und öffne dann mit dem angenehmen Gefühl in dir die Augen."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
1136
package-lock.json
generated
Normal file
1136
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
package.json
Normal file
14
package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "exercise-cards-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"pg": "^8.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
public/import.html
Normal file
42
public/import.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Exercise Cards Import</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:Arial,sans-serif;max-width:900px;margin:24px auto;padding:0 16px}
|
||||||
|
textarea{width:100%;height:320px;font-family:monospace}
|
||||||
|
.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
|
||||||
|
button{padding:8px 12px}
|
||||||
|
pre{background:#f6f6f6;padding:10px;overflow:auto}
|
||||||
|
</style></head><body>
|
||||||
|
<h2>JSON-Import für Übungskarten</h2>
|
||||||
|
<p>Erwartetes Format: <code>[{"output": {...}}, ...]</code></p>
|
||||||
|
<div class="row">
|
||||||
|
<input type="file" id="file" accept="application/json,.json,text/plain" />
|
||||||
|
<label><input type="checkbox" id="replace" checked /> Alte Datensätze vorher löschen</label>
|
||||||
|
<button id="importBtn">Importieren</button>
|
||||||
|
<a href="/">Zur Kartenansicht</a>
|
||||||
|
</div>
|
||||||
|
<p>Oder JSON direkt einfügen:</p>
|
||||||
|
<textarea id="json"></textarea>
|
||||||
|
<h3>Ergebnis</h3>
|
||||||
|
<pre id="out">-</pre>
|
||||||
|
<script>
|
||||||
|
const jsonEl=document.getElementById('json');
|
||||||
|
const out=document.getElementById('out');
|
||||||
|
|
||||||
|
document.getElementById('file').addEventListener('change', async (e)=>{
|
||||||
|
const f=e.target.files?.[0]; if(!f) return;
|
||||||
|
jsonEl.value=await f.text();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('importBtn').onclick=async()=>{
|
||||||
|
try{
|
||||||
|
const payload={ items: JSON.parse(jsonEl.value), replace: document.getElementById('replace').checked };
|
||||||
|
const r=await fetch('/api/import',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||||
|
const j=await r.json();
|
||||||
|
out.textContent=JSON.stringify(j,null,2);
|
||||||
|
}catch(e){
|
||||||
|
out.textContent='Fehler: '+e.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
682
public/index.html
Normal file
682
public/index.html
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
<!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>
|
||||||
|
</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>
|
||||||
|
</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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
aiEtaBaseSec = null;
|
||||||
|
aiEtaBaseTs = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aiEtaBaseSec != null && aiEtaBaseTs != null && total > done) {
|
||||||
|
const countdown = Math.max(0, aiEtaBaseSec - ((now - aiEtaBaseTs) / 1000));
|
||||||
|
etaTxt = ` – 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('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>
|
||||||
467
src/server.js
Normal file
467
src/server.js
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 8097);
|
||||||
|
const OPENAI_API_KEY = String(process.env.OPENAI_API_KEY || '').trim();
|
||||||
|
const OPENAI_MODEL = String(process.env.OPENAI_MODEL || 'gpt-5-mini').trim();
|
||||||
|
|
||||||
|
const PROMPT_LOCKED_TAIL = `
|
||||||
|
### FALL A: Wenn es ein OBERKAPITEL ist
|
||||||
|
{
|
||||||
|
"oberkapitel": true,
|
||||||
|
"titel": "Hier den Titel einfügen",
|
||||||
|
"zitat": "Hier das Zitat einfügen",
|
||||||
|
"Author": "Hier den Autor/Quelle einfügen",
|
||||||
|
"Beschreibung": "Hier die allgemeine Einleitung einfügen"
|
||||||
|
}
|
||||||
|
|
||||||
|
### FALL B: Wenn es ein UNTERKAPITEL / ÜBUNG ist
|
||||||
|
{
|
||||||
|
"oberkapitel": false,
|
||||||
|
"titel": "Hier den Namen der Übung einfügen",
|
||||||
|
"Dauer": "Hier Zeitangabe einfügen",
|
||||||
|
"kurzbeschreibung": "Hier einen Satz zur Wirkung einfügen",
|
||||||
|
"alter": "Hier Altersangabe einfügen (optional)",
|
||||||
|
"Tip": "Hier einen Praxistipp einfügen",
|
||||||
|
"langtext": true/false,
|
||||||
|
"inhalt": "Hier die Schritt-für-Schritt-Anleitung einfügen"
|
||||||
|
}
|
||||||
|
|
||||||
|
RECHTLICHE UND FORMALE VORGABEN:
|
||||||
|
- Das Feld "alter" darf keine Sonderzeichen wie < > im Key haben.
|
||||||
|
- Das JSON muss valide sein (keine fehlenden Kommas, keine doppelten Klammern).
|
||||||
|
- Antworte nur mit dem JSON-Block.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DEFAULT_PROMPT_EDITABLE = `Der Benutzer gibt die kapitel und anleitungen aus einem Joga-Buch. Jede dieser Eingaben sollen eigenständig verwendet werden können, als karten.
|
||||||
|
Passe die Texte (Beschreibung/Inhalt/Tip) dass sie der Textlänge ohne die Aussage und den sprachlichen Duktus zu verändern.
|
||||||
|
Achte darauf, dass bei Oberkapiteln nur die unten angegebenen Felder verwendet werden.
|
||||||
|
Lange Texte (inhalt) die im original über 300 worte sind, wie beispielsweise bei den Traumtreisen und der Meditation sollen als Langtext gekennzeichnet werden und maximal 400 worte enthalten.
|
||||||
|
Verwende niemals aufzählungen sondern nur einzelne Absätze (mit zwei \n)
|
||||||
|
Für jedes aufzählunselement soll ein absatz verwendet werden (lieber zuviel als zu wenig)
|
||||||
|
Achte darauf, dass auch die Kurztexte in viele absätze unterteilt sind
|
||||||
|
Folgende Textlängen sind wichtig einzuhalten:
|
||||||
|
Kurzbeschreibung: 30 bis 50 Worte
|
||||||
|
Tip + Inhalt zusammen 100 bis 150 Worte (Langtext 400 Worte)`;
|
||||||
|
|
||||||
|
const generationJobs = new Map();
|
||||||
|
let activeGenerationJobId = null;
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
host: process.env.DB_HOST || 'host.containers.internal',
|
||||||
|
port: Number(process.env.DB_PORT || 5433),
|
||||||
|
database: process.env.DB_NAME || 'openclaw',
|
||||||
|
user: process.env.DB_USER || 'openclaw',
|
||||||
|
password: process.env.DB_PASSWORD || '3P7m!dQ9vL2xR8kN'
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024 } });
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.static('public'));
|
||||||
|
|
||||||
|
const seedData = [
|
||||||
|
{ output: { oberkapitel: true, titel: 'KAPITEL 1: DEN KÖRPER LOCKERN UND MOBILISIEREN', zitat: '„Mach dich locker."', Author: 'deutsche Redewendung', Beschreibung: 'Stress, langes Sitzen, zu viel Bildschirmarbeit oder Bewegungsmangel führen häufig zu Muskelverspannungen im Körper. Mit den folgenden Übungen kannst du deinen Körper wieder lockern und aktiv gegen Nackenverspannungen, Spannungskopfschmerzen, verhärtete Schultermuskeln und Rückenschmerzen vorgehen.' } },
|
||||||
|
{ output: { oberkapitel: false, titel: 'Rückenwelle', Dauer: '1–3 Minuten', kurzbeschreibung: 'Die Rückenwelle mobilisiert die Wirbelsäule, dehnt Rücken- und Bauchmuskulatur und entlastet Halswirbelsäule sowie Schultergürtel. In einem ruhigen, fließenden Bewegungsablauf löst sie Verspannungen, fördert die Durchblutung und schafft mehr Beweglichkeit und Entspannung im Alltag.', alter: 'Für jedes Alter geeignet', Tip: 'Praktischer Tipp: Führe die Rückenwelle langsam und bewusst aus, ohne Schwung. Achte darauf, die Hände während der gesamten Übung auf den Oberschenkeln zu lassen und den Atem mit der Bewegung zu verbinden. Bei Schmerzen reduziere den Bewegungsumfang.', langtext: false, inhalt: 'Setze dich fest auf einen Stuhl ohne dich anzulehnen oder stelle dich hin, die Füße hüftbreit, die Knie locker. Lege die Hände auf die Oberschenkel und atme ruhig. Wölbe beim Ausatmen den Rücken zum Katzenbuckel, ziehe das Kinn zur Brust. Beim Einatmen komm in den Kuhrücken, Schambein nach hinten, Brust nach vorn, Schultern verbinden sich am Rücken, Blick hebt leicht. Bewege die Wirbelsäule langsam zwischen beiden Positionen, synchronisiere Ein- und Ausatmung und wiederhole die Welle für 1–3 Minuten. Stoppe bei Schmerzen.' } },
|
||||||
|
{ output: { oberkapitel: false, titel: 'Bootsfahrt mit Delfinen', Dauer: 'etwa 10 Minuten', kurzbeschreibung: 'Eine kurze, geführte Entspannung, die dich auf eine imaginäre Bootsfahrt mit Delfinen führt. Sie beruhigt den Atem, löst Spannungen und weckt ein Gefühl von Leichtigkeit, Freude und Verbundenheit mit Natur und Körper in etwa zehn Minuten.', Tip: 'Wenn möglich such dir einen ruhigen, bequemen Ort. Trage leichte Kleidung und nimm dir die volle Zeit, ohne Störungen. Erlaube dir, die Bilder lebendig zu spüren, ohne sie erzwingen zu müssen.', langtext: false, inhalt: 'Lege dich bequem oder setze dich, so dass du dich vollkommen entspannen kannst. Richte die Aufmerksamkeit einige Atemzüge auf den Bauch und spüre das Heben und Senken wie Wellen. Stelle dir ein kleines Boot am Bauchnabel vor, setze dich hinein und nimm die Sonne, den Wind und das Meer wahr. Beobachte, wie Delfine näherkommen; genieße ihr Spiel. Wenn du magst, stell dir vor, wie du ins warme Wasser gleitest, einen Delfin streichelst und dich von ihm ziehen lässt. Spüre die Leichtigkeit und die Freude. Lass dich schließlich zurück zum Boot bringen, atme bewusst, bewege langsam Hände und Füße und öffne die Augen.' } }
|
||||||
|
];
|
||||||
|
|
||||||
|
function stripCodeFences(s) {
|
||||||
|
return String(s || '').replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidOutputShape(o) {
|
||||||
|
if (!o || typeof o !== 'object' || Array.isArray(o)) return false;
|
||||||
|
if (typeof o.titel !== 'string') return false;
|
||||||
|
if (typeof o.oberkapitel !== 'boolean') return false;
|
||||||
|
if (o.oberkapitel) {
|
||||||
|
return typeof o.Beschreibung === 'string';
|
||||||
|
}
|
||||||
|
return typeof o.kurzbeschreibung === 'string' && typeof o.inhalt === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentPromptEditable() {
|
||||||
|
const { rows } = await pool.query('SELECT prompt_editable FROM prompt_versions ORDER BY id DESC LIMIT 1');
|
||||||
|
if (!rows.length) return DEFAULT_PROMPT_EDITABLE;
|
||||||
|
return String(rows[0].prompt_editable || DEFAULT_PROMPT_EDITABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePromptVersion(promptEditable) {
|
||||||
|
await pool.query('INSERT INTO prompt_versions(prompt_editable, prompt_full) VALUES ($1, $2)', [promptEditable, `${promptEditable}\n\n${PROMPT_LOCKED_TAIL}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callOpenAI(systemPrompt, userPrompt, job = null) {
|
||||||
|
if (!OPENAI_API_KEY) throw new Error('OPENAI_API_KEY fehlt');
|
||||||
|
const controller = new AbortController();
|
||||||
|
if (job) job.currentController = controller;
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 90000);
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${OPENAI_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: OPENAI_MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.name === 'AbortError') {
|
||||||
|
if (job?.cancelRequested) throw new Error('Abgebrochen');
|
||||||
|
throw new Error('OpenAI Timeout nach 90s');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (job && job.currentController === controller) job.currentController = null;
|
||||||
|
}
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(`OpenAI Fehler ${r.status}: ${JSON.stringify(j).slice(0, 400)}`);
|
||||||
|
const text = j?.choices?.[0]?.message?.content || '';
|
||||||
|
return stripCodeFences(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initDb() {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS exercise_cards (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sort_order INTEGER NOT NULL,
|
||||||
|
selected_for_test BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
input JSONB,
|
||||||
|
output JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await pool.query('ALTER TABLE exercise_cards ADD COLUMN IF NOT EXISTS selected_for_test BOOLEAN NOT NULL DEFAULT false');
|
||||||
|
await pool.query('ALTER TABLE exercise_cards ADD COLUMN IF NOT EXISTS input JSONB');
|
||||||
|
await pool.query('UPDATE exercise_cards SET input = output WHERE input IS NULL');
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS prompt_versions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
prompt_editable TEXT NOT NULL,
|
||||||
|
prompt_full TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const promptCount = await pool.query('SELECT COUNT(*)::int AS c FROM prompt_versions');
|
||||||
|
if (promptCount.rows[0].c === 0) await savePromptVersion(DEFAULT_PROMPT_EDITABLE);
|
||||||
|
|
||||||
|
const { rows } = await pool.query('SELECT COUNT(*)::int AS c FROM exercise_cards');
|
||||||
|
if (rows[0].c === 0) {
|
||||||
|
for (let i = 0; i < seedData.length; i++) {
|
||||||
|
const entry = structuredClone(seedData[i]);
|
||||||
|
entry.output.images = { main: null, detail: null };
|
||||||
|
await pool.query('INSERT INTO exercise_cards(sort_order, input, output) VALUES ($1, $2::jsonb, $2::jsonb)', [i + 1, JSON.stringify(entry.output)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/healthz', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/cards', async (_req, res) => {
|
||||||
|
const { rows } = await pool.query('SELECT id, sort_order, selected_for_test, input, output FROM exercise_cards ORDER BY sort_order, id');
|
||||||
|
res.json({ items: rows.map((r) => ({ id: r.id, sort_order: r.sort_order, selected_for_test: r.selected_for_test, input: r.input, output: r.output })) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/cards/:id/field', async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const field = String(req.body?.field || '').trim();
|
||||||
|
const value = String(req.body?.value ?? '');
|
||||||
|
if (!id || !field) return res.status(400).json({ ok: false, error: 'id and field required' });
|
||||||
|
|
||||||
|
const { rows } = await pool.query('SELECT output FROM exercise_cards WHERE id=$1', [id]);
|
||||||
|
if (!rows.length) return res.status(404).json({ ok: false, error: 'card not found' });
|
||||||
|
const out = rows[0].output || {};
|
||||||
|
out[field] = value;
|
||||||
|
await pool.query('UPDATE exercise_cards SET output=$2::jsonb, updated_at=NOW() WHERE id=$1', [id, JSON.stringify(out)]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/cards/:id/image/:slot', upload.single('file'), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const slot = String(req.params.slot || '').trim();
|
||||||
|
if (!id || !['main', 'detail'].includes(slot)) return res.status(400).json({ ok: false, error: 'invalid id/slot' });
|
||||||
|
if (!req.file) return res.status(400).json({ ok: false, error: 'file required' });
|
||||||
|
|
||||||
|
const mime = req.file.mimetype || 'application/octet-stream';
|
||||||
|
const b64 = req.file.buffer.toString('base64');
|
||||||
|
const dataUrl = `data:${mime};base64,${b64}`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query('SELECT output FROM exercise_cards WHERE id=$1', [id]);
|
||||||
|
if (!rows.length) return res.status(404).json({ ok: false, error: 'card not found' });
|
||||||
|
const out = rows[0].output || {};
|
||||||
|
out.images = out.images || { main: null, detail: null };
|
||||||
|
out.images[slot] = dataUrl;
|
||||||
|
|
||||||
|
await pool.query('UPDATE exercise_cards SET output=$2::jsonb, updated_at=NOW() WHERE id=$1', [id, JSON.stringify(out)]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/cards/:id/input', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const input = req.body?.input;
|
||||||
|
const sortOrder = req.body?.sort_order;
|
||||||
|
if (!id || !input || typeof input !== 'object' || Array.isArray(input)) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'id and object input required' });
|
||||||
|
}
|
||||||
|
const so = Number.isFinite(Number(sortOrder)) ? Number(sortOrder) : null;
|
||||||
|
if (so !== null) {
|
||||||
|
await pool.query('UPDATE exercise_cards SET sort_order=$2, input=$3::jsonb, updated_at=NOW() WHERE id=$1', [id, so, JSON.stringify(input)]);
|
||||||
|
} else {
|
||||||
|
await pool.query('UPDATE exercise_cards SET input=$2::jsonb, updated_at=NOW() WHERE id=$1', [id, JSON.stringify(input)]);
|
||||||
|
}
|
||||||
|
return res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ ok: false, error: e.message || String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/cards/:id/output', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const output = req.body?.output;
|
||||||
|
const sortOrder = req.body?.sort_order;
|
||||||
|
if (!id || !output || typeof output !== 'object' || Array.isArray(output)) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'id and object output required' });
|
||||||
|
}
|
||||||
|
const so = Number.isFinite(Number(sortOrder)) ? Number(sortOrder) : null;
|
||||||
|
if (so !== null) {
|
||||||
|
await pool.query('UPDATE exercise_cards SET sort_order=$2, output=$3::jsonb, updated_at=NOW() WHERE id=$1', [id, so, JSON.stringify(output)]);
|
||||||
|
} else {
|
||||||
|
await pool.query('UPDATE exercise_cards SET output=$2::jsonb, updated_at=NOW() WHERE id=$1', [id, JSON.stringify(output)]);
|
||||||
|
}
|
||||||
|
return res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ ok: false, error: e.message || String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/prompt/current', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const promptEditable = await getCurrentPromptEditable();
|
||||||
|
res.json({ ok: true, prompt_editable: promptEditable, prompt_locked: PROMPT_LOCKED_TAIL });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: e.message || String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/prompt/versions', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query('SELECT id, prompt_editable, created_at FROM prompt_versions ORDER BY id DESC');
|
||||||
|
res.json({ ok: true, items: rows });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: e.message || String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/prompt/version', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const promptEditable = String(req.body?.prompt_editable || '').trim();
|
||||||
|
if (!promptEditable) return res.status(400).json({ ok: false, error: 'prompt_editable required' });
|
||||||
|
await savePromptVersion(promptEditable);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: e.message || String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/cards/test-selection', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map((x) => Number(x)).filter(Number.isFinite) : [];
|
||||||
|
await pool.query('BEGIN');
|
||||||
|
await pool.query('UPDATE exercise_cards SET selected_for_test=false');
|
||||||
|
if (ids.length) await pool.query('UPDATE exercise_cards SET selected_for_test=true WHERE id = ANY($1::int[])', [ids]);
|
||||||
|
await pool.query('COMMIT');
|
||||||
|
res.json({ ok: true, count: ids.length });
|
||||||
|
} catch (e) {
|
||||||
|
await pool.query('ROLLBACK').catch(() => {});
|
||||||
|
res.status(500).json({ ok: false, error: e.message || String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/generate/active', (_req, res) => {
|
||||||
|
if (!activeGenerationJobId) return res.json({ ok: true, active: null });
|
||||||
|
const job = generationJobs.get(activeGenerationJobId);
|
||||||
|
if (!job) {
|
||||||
|
activeGenerationJobId = null;
|
||||||
|
return res.json({ ok: true, active: null });
|
||||||
|
}
|
||||||
|
return res.json({ ok: true, active: { id: job.id, status: job.status, total: job.total, done: job.done, progress: job.total ? Math.round((job.done / job.total) * 100) : 0 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/generate/job/:id/cancel', (req, res) => {
|
||||||
|
const id = String(req.params.id || '');
|
||||||
|
const job = generationJobs.get(id);
|
||||||
|
if (!job) return res.status(404).json({ ok: false, error: 'job not found' });
|
||||||
|
if (['done', 'failed', 'aborted'].includes(job.status)) return res.json({ ok: true, status: job.status });
|
||||||
|
job.cancelRequested = true;
|
||||||
|
job.status = 'cancelling';
|
||||||
|
if (job.currentController) {
|
||||||
|
try { job.currentController.abort(); } catch {}
|
||||||
|
}
|
||||||
|
job.updated = Date.now();
|
||||||
|
res.json({ ok: true, status: job.status });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/generate/run', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map((x)=>Number(x)).filter(Number.isFinite) : null;
|
||||||
|
const promptEditable = String(req.body?.prompt_editable || '').trim();
|
||||||
|
if (!promptEditable) return res.status(400).json({ ok: false, error: 'prompt_editable required' });
|
||||||
|
|
||||||
|
if (activeGenerationJobId) {
|
||||||
|
const active = generationJobs.get(activeGenerationJobId);
|
||||||
|
if (active && !['done', 'failed', 'aborted'].includes(active.status)) {
|
||||||
|
return res.status(409).json({ ok: false, error: 'Eine Generierung läuft bereits', jobId: active.id });
|
||||||
|
}
|
||||||
|
activeGenerationJobId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await savePromptVersion(promptEditable);
|
||||||
|
|
||||||
|
const jobId = crypto.randomUUID();
|
||||||
|
generationJobs.set(jobId, { id: jobId, status: 'running', total: 0, done: 0, errors: [], updated: Date.now(), cancelRequested: false, currentController: null });
|
||||||
|
activeGenerationJobId = jobId;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const job = generationJobs.get(jobId);
|
||||||
|
try {
|
||||||
|
const filterSql = ids && ids.length
|
||||||
|
? 'SELECT id, input FROM exercise_cards WHERE id = ANY($1::int[]) ORDER BY sort_order, id'
|
||||||
|
: 'SELECT id, input FROM exercise_cards WHERE selected_for_test=true ORDER BY sort_order, id';
|
||||||
|
const q = ids && ids.length ? await pool.query(filterSql, [ids]) : await pool.query(filterSql);
|
||||||
|
const rows = q.rows || [];
|
||||||
|
job.total = rows.length;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (job.cancelRequested) {
|
||||||
|
job.status = 'aborted';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const userPrompt = JSON.stringify({ titel: row.input?.titel || '', card: row.input || {} }, null, 2);
|
||||||
|
|
||||||
|
let success = false;
|
||||||
|
let lastError = null;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
|
if (job.cancelRequested) {
|
||||||
|
job.status = 'aborted';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = await callOpenAI(`${promptEditable}\n\n${PROMPT_LOCKED_TAIL}`, userPrompt, job);
|
||||||
|
if (job.cancelRequested) {
|
||||||
|
job.status = 'aborted';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!isValidOutputShape(parsed)) throw new Error('Schema ungültig');
|
||||||
|
await pool.query('UPDATE exercise_cards SET output=$2::jsonb, updated_at=NOW() WHERE id=$1', [row.id, JSON.stringify(parsed)]);
|
||||||
|
success = true;
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
if (job.cancelRequested) {
|
||||||
|
job.status = 'aborted';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lastError = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success && job.status !== 'aborted') {
|
||||||
|
job.errors.push({ id: row.id, error: (lastError?.message || String(lastError || 'Unbekannter Fehler')) + ' (nach 3 Versuchen)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
job.done += 1;
|
||||||
|
job.updated = Date.now();
|
||||||
|
}
|
||||||
|
if (job.status !== 'aborted') job.status = 'done';
|
||||||
|
} catch (e) {
|
||||||
|
job.status = 'failed';
|
||||||
|
job.errors.push({ error: e.message || String(e) });
|
||||||
|
} finally {
|
||||||
|
job.updated = Date.now();
|
||||||
|
job.currentController = null;
|
||||||
|
if (activeGenerationJobId === job.id) activeGenerationJobId = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
res.json({ ok: true, jobId });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: e.message || String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/generate/job/:id', (req, res) => {
|
||||||
|
const job = generationJobs.get(String(req.params.id || ''));
|
||||||
|
if (!job) return res.status(404).json({ ok: false, error: 'job not found' });
|
||||||
|
res.json({ ok: true, ...job, progress: job.total ? Math.round((job.done / job.total) * 100) : 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/reorder', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const order = req.body?.order;
|
||||||
|
if (!Array.isArray(order)) return res.status(400).json({ ok: false, error: 'order array required' });
|
||||||
|
|
||||||
|
await pool.query('BEGIN');
|
||||||
|
for (let i = 0; i < order.length; i++) {
|
||||||
|
const id = Number(order[i]);
|
||||||
|
if (!Number.isFinite(id)) continue;
|
||||||
|
await pool.query('UPDATE exercise_cards SET sort_order=$2, updated_at=NOW() WHERE id=$1', [id, i + 1]);
|
||||||
|
}
|
||||||
|
await pool.query('COMMIT');
|
||||||
|
res.json({ ok: true, count: order.length });
|
||||||
|
} catch (e) {
|
||||||
|
await pool.query('ROLLBACK').catch(() => {});
|
||||||
|
res.status(500).json({ ok: false, error: e.message || String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/import', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const items = req.body?.items;
|
||||||
|
const replace = req.body?.replace !== false;
|
||||||
|
if (!Array.isArray(items)) return res.status(400).json({ ok: false, error: 'items must be an array' });
|
||||||
|
|
||||||
|
await pool.query('BEGIN');
|
||||||
|
if (replace) await pool.query('DELETE FROM exercise_cards');
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const out = (items[i] && items[i].output) ? structuredClone(items[i].output) : null;
|
||||||
|
if (!out || typeof out !== 'object') continue;
|
||||||
|
out.images = out.images || { main: null, detail: null };
|
||||||
|
await pool.query('INSERT INTO exercise_cards(sort_order, input, output) VALUES ($1, $2::jsonb, $2::jsonb)', [i + 1, JSON.stringify(out)]);
|
||||||
|
inserted += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query('COMMIT');
|
||||||
|
res.json({ ok: true, inserted, replace });
|
||||||
|
} catch (e) {
|
||||||
|
await pool.query('ROLLBACK').catch(() => {});
|
||||||
|
res.status(500).json({ ok: false, error: e.message || String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
initDb().then(() => {
|
||||||
|
app.listen(PORT, '0.0.0.0', () => console.log(`exercise-cards-app listening on :${PORT}`));
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error('DB init failed', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user