fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit 21a844cb8a
1986 changed files with 744143 additions and 1731 deletions

34
h5p-service/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# Dependencies
node_modules/
package-lock.json
# Test Coverage
coverage/
*.lcov
# Logs
logs/
*.log
npm-debug.log*
# Environment
.env
.env.local
.env.*.local
# H5P Content
h5p-content/
h5p-libraries/
h5p-temp/
h5p-core/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db

31
h5p-service/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies
RUN apk add --no-cache curl unzip
# Copy package files
COPY package*.json ./
# Copy setup script (needed for postinstall)
COPY setup-h5p.js ./
# Install npm packages (postinstall will run setup-h5p.js)
RUN npm install --production
# Copy application code
COPY . .
# Create directories for H5P content
RUN mkdir -p h5p-content h5p-libraries h5p-temp h5p-core
# Expose port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# Start server (using simplified version for now)
CMD ["node", "server-simple.js"]

View File

@@ -0,0 +1,390 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Course Presentation Editor - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 1100px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.info-box {
background: #f0f9ff;
border-left: 4px solid #3b82f6;
padding: 16px;
margin-bottom: 24px;
border-radius: 4px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
input[type="text"], input[type="url"], textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s;
}
input[type="text"]:focus, input[type="url"]:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 120px;
}
.slides-section {
background: #f9fafb;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.slide-card {
background: white;
border: 2px solid #e5e7eb;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
transition: all 0.2s;
}
.slide-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
}
.slide-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.slide-number {
font-weight: 700;
color: #667eea;
font-size: 18px;
}
.slide-actions {
display: flex;
gap: 8px;
}
.color-picker-wrapper {
display: flex;
gap: 8px;
align-items: center;
}
input[type="color"] {
width: 60px;
height: 40px;
border: 2px solid #e5e7eb;
border-radius: 6px;
cursor: pointer;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-group {
display: flex;
gap: 12px;
margin-top: 24px;
}
.add-slide-btn {
width: 100%;
padding: 16px;
border: 2px dashed #9ca3af;
background: transparent;
color: #6b7280;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
}
.add-slide-btn:hover {
border-color: #667eea;
color: #667eea;
background: #f9fafb;
}
.success-message {
background: #d1fae5;
border: 2px solid #10b981;
color: #065f46;
padding: 16px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.hint {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
</style>
</head>
<body>
<div class="container">
<h1>
<span>📊</span>
Course Presentation Editor
</h1>
<div class="success-message" id="successMessage">
✅ Präsentation erfolgreich gespeichert!
</div>
<div class="info-box">
<h3 style="color: #1e40af; margin-bottom: 8px; font-size: 14px;">💡 Wie funktioniert Course Presentation?</h3>
<p style="color: #475569; font-size: 14px; line-height: 1.6;">
Erstelle eine interaktive Präsentation mit mehreren Folien. Jede Folie kann Titel, Text und Bilder enthalten.
Ideal für Kurse, Tutorials und Lernmaterialien.
</p>
</div>
<div class="form-group">
<label for="title">Titel der Präsentation</label>
<input type="text" id="title" placeholder="z.B. Einführung in die Programmierung">
</div>
<div class="form-group">
<label for="description">Beschreibung (optional)</label>
<textarea id="description" placeholder="Kurze Beschreibung der Präsentation..."></textarea>
</div>
<div class="slides-section">
<h3 style="margin-bottom: 16px; color: #374151;">📄 Folien</h3>
<div id="slidesContainer"></div>
<button class="add-slide-btn" onclick="addSlide()">+ Neue Folie hinzufügen</button>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="savePresentation()">💾 Speichern</button>
<button class="btn btn-secondary" onclick="previewPresentation()">📊 Vorschau</button>
<button class="btn btn-secondary" onclick="window.history.back()">← Zurück</button>
</div>
</div>
<script>
let slides = [];
function addSlide() {
const slideId = Date.now();
slides.push({
id: slideId,
title: '',
content: '',
imageUrl: '',
backgroundColor: '#ffffff'
});
renderSlides();
}
function removeSlide(slideId) {
if (slides.length <= 1) {
alert('Du musst mindestens eine Folie haben!');
return;
}
if (confirm('Folie wirklich löschen?')) {
slides = slides.filter(s => s.id !== slideId);
renderSlides();
}
}
function moveSlide(slideId, direction) {
const index = slides.findIndex(s => s.id === slideId);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= slides.length) return;
// Swap slides
[slides[index], slides[newIndex]] = [slides[newIndex], slides[index]];
renderSlides();
}
function updateSlide(slideId, field, value) {
const slide = slides.find(s => s.id === slideId);
if (slide) {
slide[field] = value;
}
}
function renderSlides() {
const container = document.getElementById('slidesContainer');
container.innerHTML = '';
slides.forEach((slide, index) => {
const slideEl = document.createElement('div');
slideEl.className = 'slide-card';
slideEl.innerHTML = `
<div class="slide-header">
<span class="slide-number">📄 Folie ${index + 1}</span>
<div class="slide-actions">
${index > 0 ? `<button class="btn btn-secondary btn-sm" onclick="moveSlide(${slide.id}, 'up')">↑</button>` : ''}
${index < slides.length - 1 ? `<button class="btn btn-secondary btn-sm" onclick="moveSlide(${slide.id}, 'down')">↓</button>` : ''}
<button class="btn btn-danger btn-sm" onclick="removeSlide(${slide.id})">🗑️</button>
</div>
</div>
<div class="form-group">
<label>Titel der Folie</label>
<input type="text"
value="${slide.title}"
onchange="updateSlide(${slide.id}, 'title', this.value)"
placeholder="z.B. Einführung">
</div>
<div class="form-group">
<label>Inhalt</label>
<textarea
onchange="updateSlide(${slide.id}, 'content', this.value)"
placeholder="Text der Folie...">${slide.content}</textarea>
</div>
<div class="form-group">
<label>Bild-URL (optional)</label>
<input type="url"
value="${slide.imageUrl}"
onchange="updateSlide(${slide.id}, 'imageUrl', this.value)"
placeholder="https://example.com/image.jpg">
<div class="hint">URL zu einem Bild, das auf der Folie angezeigt werden soll</div>
</div>
<div class="form-group">
<label>Hintergrundfarbe</label>
<div class="color-picker-wrapper">
<input type="color"
value="${slide.backgroundColor}"
onchange="updateSlide(${slide.id}, 'backgroundColor', this.value)">
<span style="color: #6b7280; font-size: 14px;">${slide.backgroundColor}</span>
</div>
</div>
`;
container.appendChild(slideEl);
});
}
function savePresentation() {
const title = document.getElementById('title').value;
const description = document.getElementById('description').value;
if (!title) {
alert('Bitte gib einen Titel ein!');
return;
}
if (slides.length === 0) {
alert('Bitte füge mindestens eine Folie hinzu!');
return;
}
for (let i = 0; i < slides.length; i++) {
if (!slides[i].title) {
alert(`Folie ${i + 1} hat keinen Titel!`);
return;
}
if (!slides[i].content) {
alert(`Folie ${i + 1} hat keinen Inhalt!`);
return;
}
}
const presentationData = {
type: 'course-presentation',
title,
description,
slides,
created: new Date().toISOString()
};
const contentId = 'presentation_' + Date.now();
localStorage.setItem(contentId, JSON.stringify(presentationData));
const successMsg = document.getElementById('successMessage');
successMsg.style.display = 'block';
setTimeout(() => {
successMsg.style.display = 'none';
}, 3000);
console.log('Präsentation gespeichert:', presentationData);
}
function previewPresentation() {
const title = document.getElementById('title').value;
if (!title || slides.length === 0) {
alert('Bitte fülle erst alle Felder aus!');
return;
}
const previewData = encodeURIComponent(JSON.stringify({
title,
description: document.getElementById('description').value,
slides
}));
window.open(`/h5p/player/course-presentation?data=${previewData}`, '_blank');
}
// Initialize with 3 slides
addSlide();
addSlide();
addSlide();
</script>
</body>
</html>

View File

@@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drag and Drop Editor - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 900px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.info-box {
background: #f0f9ff;
border-left: 4px solid #3b82f6;
padding: 16px;
margin-bottom: 24px;
border-radius: 4px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
input[type="text"], textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s;
}
input[type="text"]:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 80px;
}
.zones-section {
background: #f9fafb;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.zone-card {
background: white;
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.zone-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.zone-number {
font-weight: 600;
color: #667eea;
font-size: 14px;
}
.draggables-section {
background: #fef3c7;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.draggable-card {
background: white;
border: 2px solid #f59e0b;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
display: flex;
gap: 12px;
align-items: center;
}
.draggable-card input[type="text"] {
flex: 1;
}
select {
padding: 8px 12px;
border: 2px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
background: white;
cursor: pointer;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-group {
display: flex;
gap: 12px;
margin-top: 24px;
}
.add-btn {
width: 100%;
padding: 12px;
border: 2px dashed #9ca3af;
background: transparent;
color: #6b7280;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
margin-top: 12px;
}
.add-btn:hover {
border-color: #667eea;
color: #667eea;
background: #f9fafb;
}
.success-message {
background: #d1fae5;
border: 2px solid #10b981;
color: #065f46;
padding: 16px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
</style>
</head>
<body>
<div class="container">
<h1>
<span>🎯</span>
Drag and Drop Editor
</h1>
<div class="success-message" id="successMessage">
✅ Drag and Drop Aufgabe erfolgreich gespeichert!
</div>
<div class="info-box">
<h3 style="color: #1e40af; margin-bottom: 8px; font-size: 14px;">💡 Wie funktioniert Drag and Drop?</h3>
<p style="color: #475569; font-size: 14px; line-height: 1.6;">
Erstelle Ablage-Zonen und zugehörige Elemente. Lernende müssen die Elemente per Drag & Drop in die richtigen Zonen ziehen.
Ideal für Zuordnungsaufgaben, Kategorisierung und mehr.
</p>
</div>
<div class="form-group">
<label for="title">Titel der Aufgabe</label>
<input type="text" id="title" placeholder="z.B. Ordne die Tiere den richtigen Kategorien zu">
</div>
<div class="form-group">
<label for="question">Aufgabenstellung</label>
<textarea id="question" placeholder="z.B. Ziehe jedes Tier in die passende Kategorie: Säugetiere, Vögel oder Reptilien."></textarea>
</div>
<div class="zones-section">
<h3 style="margin-bottom: 16px; color: #374151;">📍 Ablage-Zonen (Drop Zones)</h3>
<div id="zonesContainer"></div>
<button class="add-btn" onclick="addZone()">+ Neue Zone hinzufügen</button>
</div>
<div class="draggables-section">
<h3 style="margin-bottom: 16px; color: #78350f;">🔲 Ziehbare Elemente (Draggables)</h3>
<div id="draggablesContainer"></div>
<button class="add-btn" onclick="addDraggable()">+ Neues Element hinzufügen</button>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveDragDrop()">💾 Speichern</button>
<button class="btn btn-secondary" onclick="previewDragDrop()">🎮 Testen</button>
<button class="btn btn-secondary" onclick="window.history.back()">← Zurück</button>
</div>
</div>
<script>
let zones = [];
let draggables = [];
function addZone() {
const zoneId = Date.now();
zones.push({
id: zoneId,
name: ''
});
renderZones();
}
function removeZone(zoneId) {
if (confirm('Zone wirklich löschen?')) {
zones = zones.filter(z => z.id !== zoneId);
renderZones();
renderDraggables(); // Re-render to update zone selections
}
}
function updateZone(zoneId, value) {
const zone = zones.find(z => z.id === zoneId);
if (zone) {
zone.name = value;
renderDraggables(); // Update dropdowns
}
}
function renderZones() {
const container = document.getElementById('zonesContainer');
container.innerHTML = '';
zones.forEach((zone, index) => {
const zoneEl = document.createElement('div');
zoneEl.className = 'zone-card';
zoneEl.innerHTML = `
<div class="zone-header">
<span class="zone-number">Zone ${index + 1}</span>
<button class="btn btn-danger" style="padding: 6px 12px; font-size: 12px;" onclick="removeZone(${zone.id})">🗑️</button>
</div>
<input type="text"
value="${zone.name}"
onchange="updateZone(${zone.id}, this.value)"
placeholder="z.B. 'Säugetiere' oder 'Vögel'">
`;
container.appendChild(zoneEl);
});
}
function addDraggable() {
const draggableId = Date.now();
draggables.push({
id: draggableId,
text: '',
correctZoneId: zones.length > 0 ? zones[0].id : null
});
renderDraggables();
}
function removeDraggable(draggableId) {
if (confirm('Element wirklich löschen?')) {
draggables = draggables.filter(d => d.id !== draggableId);
renderDraggables();
}
}
function updateDraggable(draggableId, field, value) {
const draggable = draggables.find(d => d.id === draggableId);
if (draggable) {
draggable[field] = field === 'correctZoneId' ? parseInt(value) : value;
}
}
function renderDraggables() {
const container = document.getElementById('draggablesContainer');
container.innerHTML = '';
draggables.forEach((draggable, index) => {
const draggableEl = document.createElement('div');
draggableEl.className = 'draggable-card';
draggableEl.innerHTML = `
<span style="color: #92400e; font-weight: 600; font-size: 14px;">${index + 1}.</span>
<input type="text"
value="${draggable.text}"
onchange="updateDraggable(${draggable.id}, 'text', this.value)"
placeholder="z.B. 'Hund' oder 'Adler'">
<select onchange="updateDraggable(${draggable.id}, 'correctZoneId', this.value)">
<option value="">Richtige Zone wählen...</option>
${zones.map(zone => `
<option value="${zone.id}" ${draggable.correctZoneId === zone.id ? 'selected' : ''}>
${zone.name || 'Zone ' + (zones.indexOf(zone) + 1)}
</option>
`).join('')}
</select>
<button class="btn btn-danger" style="padding: 6px 12px; font-size: 12px;" onclick="removeDraggable(${draggable.id})">🗑️</button>
`;
container.appendChild(draggableEl);
});
}
function saveDragDrop() {
const title = document.getElementById('title').value;
const question = document.getElementById('question').value;
if (!title) {
alert('Bitte gib einen Titel ein!');
return;
}
if (zones.length < 2) {
alert('Bitte füge mindestens 2 Ablage-Zonen hinzu!');
return;
}
if (draggables.length < 3) {
alert('Bitte füge mindestens 3 ziehbare Elemente hinzu!');
return;
}
for (let i = 0; i < zones.length; i++) {
if (!zones[i].name) {
alert(`Zone ${i + 1} hat keinen Namen!`);
return;
}
}
for (let i = 0; i < draggables.length; i++) {
if (!draggables[i].text) {
alert(`Element ${i + 1} hat keinen Text!`);
return;
}
if (!draggables[i].correctZoneId) {
alert(`Element ${i + 1} hat keine richtige Zone zugewiesen!`);
return;
}
}
const dragDropData = {
type: 'drag-drop',
title,
question,
zones,
draggables,
created: new Date().toISOString()
};
const contentId = 'dragdrop_' + Date.now();
localStorage.setItem(contentId, JSON.stringify(dragDropData));
const successMsg = document.getElementById('successMessage');
successMsg.style.display = 'block';
setTimeout(() => {
successMsg.style.display = 'none';
}, 3000);
console.log('Drag and Drop gespeichert:', dragDropData);
}
function previewDragDrop() {
const title = document.getElementById('title').value;
if (!title || zones.length < 2 || draggables.length < 3) {
alert('Bitte fülle erst alle Felder aus!');
return;
}
const previewData = encodeURIComponent(JSON.stringify({
title,
question: document.getElementById('question').value,
zones,
draggables
}));
window.open(`/h5p/player/drag-drop?data=${previewData}`, '_blank');
}
// Initialize with 2 zones
addZone();
addZone();
</script>
</body>
</html>

View File

@@ -0,0 +1,312 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fill in the Blanks Editor - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 900px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.info-box {
background: #f0f9ff;
border-left: 4px solid #3b82f6;
padding: 16px;
margin-bottom: 24px;
border-radius: 4px;
}
.info-box h3 {
color: #1e40af;
margin-bottom: 8px;
font-size: 14px;
}
.info-box p {
color: #475569;
font-size: 14px;
line-height: 1.6;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
input[type="text"], textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s;
}
input[type="text"]:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 120px;
font-family: monospace;
}
.preview {
background: #f9fafb;
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.preview h3 {
color: #374151;
margin-bottom: 12px;
font-size: 16px;
}
.preview-text {
font-size: 16px;
line-height: 1.8;
color: #1f2937;
}
.blank {
display: inline-block;
background: #667eea;
color: white;
padding: 2px 12px;
border-radius: 4px;
font-weight: 600;
margin: 0 4px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-group {
display: flex;
gap: 12px;
margin-top: 24px;
}
.success-message {
background: #d1fae5;
border: 2px solid #10b981;
color: #065f46;
padding: 16px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.example {
background: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 12px;
border-radius: 4px;
margin-top: 8px;
}
.example strong {
color: #92400e;
}
.example code {
background: #fffbeb;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
color: #78350f;
}
</style>
</head>
<body>
<div class="container">
<h1>
<span>📝</span>
Fill in the Blanks Editor
</h1>
<div class="success-message" id="successMessage">
✅ Lückentext erfolgreich gespeichert!
</div>
<div class="info-box">
<h3>💡 Wie funktioniert es?</h3>
<p>
Erstelle einen Text mit Lücken. Markiere die Wörter, die als Lücke erscheinen sollen, mit Sternchen: *Wort*
</p>
<div class="example">
<strong>Beispiel:</strong><br>
<code>Berlin ist die *Hauptstadt* von *Deutschland*.</code><br>
→ Wird zu: "Berlin ist die _____ von _____."
</div>
</div>
<div class="form-group">
<label for="title">Titel</label>
<input type="text" id="title" placeholder="z.B. Geografie Quiz - Deutsche Städte">
</div>
<div class="form-group">
<label for="instructions">Anleitung (optional)</label>
<input type="text" id="instructions" placeholder="z.B. Fülle die Lücken mit den richtigen Wörtern aus.">
</div>
<div class="form-group">
<label for="text">Text mit Lücken</label>
<textarea id="text" oninput="updatePreview()"
placeholder="Beispiel: Die *Sonne* ist ein *Stern*. Sie ist etwa *150* Millionen Kilometer von der Erde entfernt.">Die *Sonne* ist ein *Stern*. Sie ist etwa *150* Millionen Kilometer von der *Erde* entfernt.</textarea>
</div>
<div class="preview">
<h3>👁️ Vorschau (wie es für Lernende aussieht):</h3>
<div class="preview-text" id="previewText"></div>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveFillBlanks()">💾 Speichern</button>
<button class="btn btn-secondary" onclick="previewFillBlanks()">👁️ Testen</button>
<button class="btn btn-secondary" onclick="window.history.back()">← Zurück</button>
</div>
</div>
<script>
function updatePreview() {
const text = document.getElementById('text').value;
const previewEl = document.getElementById('previewText');
if (!text) {
previewEl.innerHTML = '<em style="color: #9ca3af;">Gib oben einen Text ein...</em>';
return;
}
// Replace *word* with blank placeholder
const previewText = text.replace(/\*([^*]+)\*/g, (match, word) => {
return `<span class="blank">_____</span>`;
});
previewEl.innerHTML = previewText;
}
function extractBlanks(text) {
const blanks = [];
const regex = /\*([^*]+)\*/g;
let match;
while ((match = regex.exec(text)) !== null) {
blanks.push({
word: match[1],
position: match.index
});
}
return blanks;
}
function saveFillBlanks() {
const title = document.getElementById('title').value;
const instructions = document.getElementById('instructions').value;
const text = document.getElementById('text').value;
if (!title) {
alert('Bitte gib einen Titel ein!');
return;
}
if (!text) {
alert('Bitte gib einen Text ein!');
return;
}
const blanks = extractBlanks(text);
if (blanks.length === 0) {
alert('Bitte markiere mindestens ein Wort mit Sternchen (*Wort*) als Lücke!');
return;
}
const fillBlanksData = {
type: 'fill-blanks',
title,
instructions,
text,
blanks,
created: new Date().toISOString()
};
const contentId = 'fillblanks_' + Date.now();
localStorage.setItem(contentId, JSON.stringify(fillBlanksData));
const successMsg = document.getElementById('successMessage');
successMsg.style.display = 'block';
setTimeout(() => {
successMsg.style.display = 'none';
}, 3000);
console.log('Fill in the Blanks gespeichert:', fillBlanksData);
}
function previewFillBlanks() {
const title = document.getElementById('title').value;
const text = document.getElementById('text').value;
if (!title || !text) {
alert('Bitte fülle erst Titel und Text aus!');
return;
}
const blanks = extractBlanks(text);
if (blanks.length === 0) {
alert('Bitte markiere mindestens ein Wort mit Sternchen (*Wort*) als Lücke!');
return;
}
const previewData = encodeURIComponent(JSON.stringify({
title,
instructions: document.getElementById('instructions').value,
text,
blanks
}));
window.open(`/h5p/player/fill-blanks?data=${previewData}`, '_blank');
}
// Initialize preview
updatePreview();
</script>
</body>
</html>

View File

@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flashcards Editor - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 900px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
input[type="text"], textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s;
}
input[type="text"]:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 100px;
}
.card {
background: #f9fafb;
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.card-number {
font-weight: 600;
color: #667eea;
font-size: 16px;
}
.card-sides {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.card-side {
background: white;
padding: 16px;
border-radius: 6px;
border: 2px solid #e5e7eb;
}
.card-side h4 {
color: #6b7280;
font-size: 12px;
text-transform: uppercase;
margin-bottom: 8px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-group {
display: flex;
gap: 12px;
margin-top: 24px;
}
.add-card-btn {
width: 100%;
padding: 16px;
border: 2px dashed #9ca3af;
background: transparent;
color: #6b7280;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
}
.add-card-btn:hover {
border-color: #667eea;
color: #667eea;
background: #f9fafb;
}
.success-message {
background: #d1fae5;
border: 2px solid #10b981;
color: #065f46;
padding: 16px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
</style>
</head>
<body>
<div class="container">
<h1>
<span>🃏</span>
Flashcards Editor
</h1>
<div class="success-message" id="successMessage">
✅ Flashcards erfolgreich gespeichert!
</div>
<div class="form-group">
<label for="title">Titel des Kartensatzes</label>
<input type="text" id="title" placeholder="z.B. Englisch Vokabeln Kapitel 3">
</div>
<div class="form-group">
<label for="description">Beschreibung (optional)</label>
<textarea id="description" placeholder="Kurze Beschreibung der Lernkarten..."></textarea>
</div>
<div id="cardsContainer"></div>
<button class="add-card-btn" onclick="addCard()">
+ Neue Karte hinzufügen
</button>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveFlashcards()">💾 Speichern</button>
<button class="btn btn-secondary" onclick="window.history.back()">← Zurück</button>
</div>
</div>
<script>
let cards = [];
function addCard() {
const cardId = Date.now();
cards.push({
id: cardId,
front: '',
back: ''
});
renderCards();
}
function removeCard(cardId) {
if (confirm('Karte wirklich löschen?')) {
cards = cards.filter(c => c.id !== cardId);
renderCards();
}
}
function updateCard(cardId, side, value) {
const card = cards.find(c => c.id === cardId);
if (card) {
card[side] = value;
}
}
function renderCards() {
const container = document.getElementById('cardsContainer');
container.innerHTML = '';
cards.forEach((card, index) => {
const cardEl = document.createElement('div');
cardEl.className = 'card';
cardEl.innerHTML = `
<div class="card-header">
<span class="card-number">Karte ${index + 1}</span>
<button class="btn btn-danger" onclick="removeCard(${card.id})">🗑️ Löschen</button>
</div>
<div class="card-sides">
<div class="card-side">
<h4>Vorderseite</h4>
<textarea
onchange="updateCard(${card.id}, 'front', this.value)"
placeholder="z.B. 'Hello'">${card.front}</textarea>
</div>
<div class="card-side">
<h4>Rückseite</h4>
<textarea
onchange="updateCard(${card.id}, 'back', this.value)"
placeholder="z.B. 'Hallo'">${card.back}</textarea>
</div>
</div>
`;
container.appendChild(cardEl);
});
}
function saveFlashcards() {
const title = document.getElementById('title').value;
const description = document.getElementById('description').value;
if (!title) {
alert('Bitte gib einen Titel ein!');
return;
}
if (cards.length === 0) {
alert('Bitte füge mindestens eine Karte hinzu!');
return;
}
for (let i = 0; i < cards.length; i++) {
if (!cards[i].front || !cards[i].back) {
alert(`Karte ${i + 1} ist nicht vollständig ausgefüllt!`);
return;
}
}
const flashcardsData = {
type: 'flashcards',
title,
description,
cards,
created: new Date().toISOString()
};
const contentId = 'flashcards_' + Date.now();
localStorage.setItem(contentId, JSON.stringify(flashcardsData));
const successMsg = document.getElementById('successMessage');
successMsg.style.display = 'block';
setTimeout(() => {
successMsg.style.display = 'none';
}, 3000);
console.log('Flashcards gespeichert:', flashcardsData);
}
// Initialize with 3 cards
addCard();
addCard();
addCard();
</script>
</body>
</html>

View File

@@ -0,0 +1,441 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive Video Editor - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 1000px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.info-box {
background: #f0f9ff;
border-left: 4px solid #3b82f6;
padding: 16px;
margin-bottom: 24px;
border-radius: 4px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
input[type="text"], input[type="url"], textarea, select {
width: 100%;
padding: 10px 14px;
border: 2px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s;
}
input[type="text"]:focus, input[type="url"]:focus, textarea:focus, select:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 80px;
}
.video-preview {
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 24px;
aspect-ratio: 16/9;
display: none;
}
.video-preview iframe {
width: 100%;
height: 100%;
}
.interactions-section {
background: #fef3c7;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.interaction-card {
background: white;
border: 2px solid #f59e0b;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.interaction-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.interaction-time {
font-weight: 700;
color: #92400e;
font-size: 16px;
}
.form-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 12px;
align-items: start;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-group {
display: flex;
gap: 12px;
margin-top: 24px;
}
.add-btn {
width: 100%;
padding: 12px;
border: 2px dashed #9ca3af;
background: transparent;
color: #6b7280;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
margin-top: 12px;
}
.add-btn:hover {
border-color: #f59e0b;
color: #f59e0b;
background: #fef3c7;
}
.success-message {
background: #d1fae5;
border: 2px solid #10b981;
color: #065f46;
padding: 16px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.time-input {
width: 120px !important;
font-family: monospace;
}
.hint {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
</style>
</head>
<body>
<div class="container">
<h1>
<span>🎬</span>
Interactive Video Editor
</h1>
<div class="success-message" id="successMessage">
✅ Interactive Video erfolgreich gespeichert!
</div>
<div class="info-box">
<h3 style="color: #1e40af; margin-bottom: 8px; font-size: 14px;">💡 Wie funktioniert Interactive Video?</h3>
<p style="color: #475569; font-size: 14px; line-height: 1.6;">
Füge einem Video interaktive Elemente wie Fragen, Infotexte oder Links hinzu.
Das Video pausiert automatisch an den definierten Zeitpunkten und zeigt die Interaktionen an.
</p>
</div>
<div class="form-group">
<label for="title">Titel des Videos</label>
<input type="text" id="title" placeholder="z.B. Einführung in die Photosynthese">
</div>
<div class="form-group">
<label for="videoUrl">Video-URL</label>
<input type="url" id="videoUrl" placeholder="https://www.youtube.com/watch?v=... oder https://player.vimeo.com/video/...">
<div class="hint">Unterstützt: YouTube, Vimeo oder direkte MP4-Links</div>
</div>
<div class="video-preview" id="videoPreview"></div>
<div class="form-group">
<label for="description">Beschreibung (optional)</label>
<textarea id="description" placeholder="Kurze Beschreibung des Video-Inhalts..."></textarea>
</div>
<div class="interactions-section">
<h3 style="margin-bottom: 16px; color: #78350f;">⏱️ Interaktive Elemente</h3>
<div id="interactionsContainer"></div>
<button class="add-btn" onclick="addInteraction()">+ Neue Interaktion hinzufügen</button>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveVideo()">💾 Speichern</button>
<button class="btn btn-secondary" onclick="previewVideo()">▶️ Vorschau</button>
<button class="btn btn-secondary" onclick="window.history.back()">← Zurück</button>
</div>
</div>
<script>
let interactions = [];
function addInteraction() {
const interactionId = Date.now();
interactions.push({
id: interactionId,
time: '00:00',
type: 'question',
title: '',
content: ''
});
renderInteractions();
}
function removeInteraction(interactionId) {
if (confirm('Interaktion wirklich löschen?')) {
interactions = interactions.filter(i => i.id !== interactionId);
renderInteractions();
}
}
function updateInteraction(interactionId, field, value) {
const interaction = interactions.find(i => i.id === interactionId);
if (interaction) {
interaction[field] = value;
}
}
function renderInteractions() {
const container = document.getElementById('interactionsContainer');
container.innerHTML = '';
// Sort by time
const sortedInteractions = [...interactions].sort((a, b) => {
const aSeconds = timeToSeconds(a.time);
const bSeconds = timeToSeconds(b.time);
return aSeconds - bSeconds;
});
sortedInteractions.forEach((interaction, index) => {
const interactionEl = document.createElement('div');
interactionEl.className = 'interaction-card';
interactionEl.innerHTML = `
<div class="interaction-header">
<span class="interaction-time">⏱️ ${interaction.time}</span>
<button class="btn btn-danger" style="padding: 6px 12px; font-size: 12px;" onclick="removeInteraction(${interaction.id})">🗑️</button>
</div>
<div class="form-row" style="margin-bottom: 12px;">
<input type="text"
class="time-input"
value="${interaction.time}"
onchange="updateInteraction(${interaction.id}, 'time', this.value); renderInteractions();"
placeholder="mm:ss">
<select onchange="updateInteraction(${interaction.id}, 'type', this.value)">
<option value="question" ${interaction.type === 'question' ? 'selected' : ''}>❓ Frage</option>
<option value="info" ${interaction.type === 'info' ? 'selected' : ''}> Information</option>
<option value="link" ${interaction.type === 'link' ? 'selected' : ''}>🔗 Link</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 12px;">
<input type="text"
value="${interaction.title}"
onchange="updateInteraction(${interaction.id}, 'title', this.value)"
placeholder="Titel der Interaktion">
</div>
<div class="form-group">
<textarea
onchange="updateInteraction(${interaction.id}, 'content', this.value)"
placeholder="${getPlaceholderForType(interaction.type)}">${interaction.content}</textarea>
</div>
`;
container.appendChild(interactionEl);
});
}
function getPlaceholderForType(type) {
switch (type) {
case 'question':
return 'Frage eingeben (z.B. Was passiert bei der Photosynthese?)';
case 'info':
return 'Informationstext eingeben';
case 'link':
return 'URL eingeben (z.B. https://example.com)';
default:
return 'Inhalt eingeben';
}
}
function timeToSeconds(timeStr) {
const parts = timeStr.split(':');
const minutes = parseInt(parts[0]) || 0;
const seconds = parseInt(parts[1]) || 0;
return minutes * 60 + seconds;
}
function saveVideo() {
const title = document.getElementById('title').value;
const videoUrl = document.getElementById('videoUrl').value;
const description = document.getElementById('description').value;
if (!title) {
alert('Bitte gib einen Titel ein!');
return;
}
if (!videoUrl) {
alert('Bitte gib eine Video-URL ein!');
return;
}
if (interactions.length === 0) {
alert('Bitte füge mindestens eine Interaktion hinzu!');
return;
}
for (let i = 0; i < interactions.length; i++) {
if (!interactions[i].title) {
alert(`Interaktion ${i + 1} hat keinen Titel!`);
return;
}
if (!interactions[i].content) {
alert(`Interaktion ${i + 1} hat keinen Inhalt!`);
return;
}
}
const videoData = {
type: 'interactive-video',
title,
videoUrl,
description,
interactions: interactions.sort((a, b) => timeToSeconds(a.time) - timeToSeconds(b.time)),
created: new Date().toISOString()
};
const contentId = 'video_' + Date.now();
localStorage.setItem(contentId, JSON.stringify(videoData));
const successMsg = document.getElementById('successMessage');
successMsg.style.display = 'block';
setTimeout(() => {
successMsg.style.display = 'none';
}, 3000);
console.log('Interactive Video gespeichert:', videoData);
}
function previewVideo() {
const title = document.getElementById('title').value;
const videoUrl = document.getElementById('videoUrl').value;
if (!title || !videoUrl || interactions.length === 0) {
alert('Bitte fülle erst alle Felder aus!');
return;
}
const previewData = encodeURIComponent(JSON.stringify({
title,
videoUrl,
description: document.getElementById('description').value,
interactions: interactions.sort((a, b) => timeToSeconds(a.time) - timeToSeconds(b.time))
}));
window.open(`/h5p/player/interactive-video?data=${previewData}`, '_blank');
}
// Video preview on URL change
document.getElementById('videoUrl').addEventListener('change', function() {
const url = this.value;
const preview = document.getElementById('videoPreview');
if (!url) {
preview.style.display = 'none';
return;
}
let embedUrl = '';
// YouTube
if (url.includes('youtube.com') || url.includes('youtu.be')) {
const videoId = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&]+)/)?.[1];
if (videoId) {
embedUrl = `https://www.youtube.com/embed/${videoId}`;
}
}
// Vimeo
else if (url.includes('vimeo.com')) {
const videoId = url.match(/vimeo\.com\/(\d+)/)?.[1];
if (videoId) {
embedUrl = `https://player.vimeo.com/video/${videoId}`;
}
}
// Direct MP4
else if (url.endsWith('.mp4')) {
preview.innerHTML = `<video controls style="width: 100%; height: 100%;"><source src="${url}" type="video/mp4"></video>`;
preview.style.display = 'block';
return;
}
if (embedUrl) {
preview.innerHTML = `<iframe src="${embedUrl}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
preview.style.display = 'block';
}
});
// Initialize with 2 interactions
addInteraction();
addInteraction();
</script>
</body>
</html>

View File

@@ -0,0 +1,330 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Memory Game Editor - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 900px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.info-box {
background: #f0f9ff;
border-left: 4px solid #3b82f6;
padding: 16px;
margin-bottom: 24px;
border-radius: 4px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
input[type="text"], textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s;
}
input[type="text"]:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
.pair-card {
background: #f9fafb;
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
.pair-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.pair-number {
font-weight: 600;
color: #667eea;
font-size: 16px;
}
.pair-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.pair-input {
background: white;
padding: 12px;
border-radius: 6px;
border: 2px solid #e5e7eb;
}
.pair-input h4 {
color: #6b7280;
font-size: 12px;
text-transform: uppercase;
margin-bottom: 8px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-group {
display: flex;
gap: 12px;
margin-top: 24px;
}
.add-pair-btn {
width: 100%;
padding: 16px;
border: 2px dashed #9ca3af;
background: transparent;
color: #6b7280;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
}
.add-pair-btn:hover {
border-color: #667eea;
color: #667eea;
background: #f9fafb;
}
.success-message {
background: #d1fae5;
border: 2px solid #10b981;
color: #065f46;
padding: 16px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
</style>
</head>
<body>
<div class="container">
<h1>
<span>🧠</span>
Memory Game Editor
</h1>
<div class="success-message" id="successMessage">
✅ Memory Game erfolgreich gespeichert!
</div>
<div class="info-box">
<h3 style="color: #1e40af; margin-bottom: 8px; font-size: 14px;">💡 Wie funktioniert Memory?</h3>
<p style="color: #475569; font-size: 14px; line-height: 1.6;">
Erstelle Kartenpaare. Die Lernenden müssen die zusammengehörenden Karten finden.
Jedes Paar besteht aus zwei Karten mit verschiedenen Inhalten (z.B. Wort ↔ Übersetzung, Begriff ↔ Definition).
</p>
</div>
<div class="form-group">
<label for="title">Titel des Memory-Spiels</label>
<input type="text" id="title" placeholder="z.B. Englisch Vokabeln - Tiere">
</div>
<div class="form-group">
<label for="description">Beschreibung (optional)</label>
<input type="text" id="description" placeholder="Kurze Beschreibung...">
</div>
<div id="pairsContainer"></div>
<button class="add-pair-btn" onclick="addPair()">
+ Neues Kartenpaar hinzufügen
</button>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveMemory()">💾 Speichern</button>
<button class="btn btn-secondary" onclick="previewMemory()">🎮 Spielen</button>
<button class="btn btn-secondary" onclick="window.history.back()">← Zurück</button>
</div>
</div>
<script>
let pairs = [];
function addPair() {
const pairId = Date.now();
pairs.push({
id: pairId,
card1: '',
card2: ''
});
renderPairs();
}
function removePair(pairId) {
if (confirm('Paar wirklich löschen?')) {
pairs = pairs.filter(p => p.id !== pairId);
renderPairs();
}
}
function updatePair(pairId, card, value) {
const pair = pairs.find(p => p.id === pairId);
if (pair) {
pair[card] = value;
}
}
function renderPairs() {
const container = document.getElementById('pairsContainer');
container.innerHTML = '';
pairs.forEach((pair, index) => {
const pairEl = document.createElement('div');
pairEl.className = 'pair-card';
pairEl.innerHTML = `
<div class="pair-header">
<span class="pair-number">Paar ${index + 1}</span>
<button class="btn btn-danger" onclick="removePair(${pair.id})">🗑️ Löschen</button>
</div>
<div class="pair-inputs">
<div class="pair-input">
<h4>Karte 1</h4>
<input type="text"
value="${pair.card1}"
onchange="updatePair(${pair.id}, 'card1', this.value)"
placeholder="z.B. 'Cat'">
</div>
<div class="pair-input">
<h4>Karte 2</h4>
<input type="text"
value="${pair.card2}"
onchange="updatePair(${pair.id}, 'card2', this.value)"
placeholder="z.B. 'Katze'">
</div>
</div>
`;
container.appendChild(pairEl);
});
}
function saveMemory() {
const title = document.getElementById('title').value;
const description = document.getElementById('description').value;
if (!title) {
alert('Bitte gib einen Titel ein!');
return;
}
if (pairs.length < 3) {
alert('Bitte füge mindestens 3 Kartenpaare hinzu!');
return;
}
for (let i = 0; i < pairs.length; i++) {
if (!pairs[i].card1 || !pairs[i].card2) {
alert(`Paar ${i + 1} ist nicht vollständig ausgefüllt!`);
return;
}
}
const memoryData = {
type: 'memory',
title,
description,
pairs,
created: new Date().toISOString()
};
const contentId = 'memory_' + Date.now();
localStorage.setItem(contentId, JSON.stringify(memoryData));
const successMsg = document.getElementById('successMessage');
successMsg.style.display = 'block';
setTimeout(() => {
successMsg.style.display = 'none';
}, 3000);
console.log('Memory gespeichert:', memoryData);
}
function previewMemory() {
const title = document.getElementById('title').value;
if (!title || pairs.length < 3) {
alert('Bitte fülle erst den Titel und mindestens 3 Paare aus!');
return;
}
for (let i = 0; i < pairs.length; i++) {
if (!pairs[i].card1 || !pairs[i].card2) {
alert(`Paar ${i + 1} ist nicht vollständig ausgefüllt!`);
return;
}
}
const previewData = encodeURIComponent(JSON.stringify({
title,
description: document.getElementById('description').value,
pairs
}));
window.open(`/h5p/player/memory?data=${previewData}`, '_blank');
}
// Initialize with 4 pairs
addPair();
addPair();
addPair();
addPair();
</script>
</body>
</html>

View File

@@ -0,0 +1,380 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quiz Editor - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 900px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
input[type="text"], textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s;
}
input[type="text"]:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 80px;
}
.question-card {
background: #f9fafb;
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
.question-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.question-number {
font-weight: 600;
color: #667eea;
font-size: 16px;
}
.answer-option {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
padding: 12px;
background: white;
border-radius: 6px;
border: 2px solid #e5e7eb;
}
.answer-option.correct {
border-color: #10b981;
background: #f0fdf4;
}
.answer-option input[type="text"] {
flex: 1;
border: none;
background: transparent;
padding: 4px 8px;
}
.answer-option input[type="text"]:focus {
background: white;
border: 1px solid #667eea;
border-radius: 4px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover {
background: #059669;
}
.btn-group {
display: flex;
gap: 12px;
margin-top: 24px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: normal;
}
input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.success-message {
background: #d1fae5;
border: 2px solid #10b981;
color: #065f46;
padding: 16px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.add-question-btn {
width: 100%;
padding: 16px;
border: 2px dashed #9ca3af;
background: transparent;
color: #6b7280;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
}
.add-question-btn:hover {
border-color: #667eea;
color: #667eea;
background: #f9fafb;
}
</style>
</head>
<body>
<div class="container">
<h1>
<span>✍️</span>
Quiz Editor (Question Set)
</h1>
<div class="success-message" id="successMessage">
✅ Quiz erfolgreich gespeichert!
</div>
<div class="form-group">
<label for="quizTitle">Quiz Titel</label>
<input type="text" id="quizTitle" placeholder="z.B. Mathematik Grundlagen Quiz">
</div>
<div class="form-group">
<label for="quizDescription">Beschreibung (optional)</label>
<textarea id="quizDescription" placeholder="Kurze Beschreibung des Quiz..."></textarea>
</div>
<div id="questionsContainer">
<!-- Questions will be added here -->
</div>
<button class="add-question-btn" onclick="addQuestion()">
+ Neue Frage hinzufügen
</button>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveQuiz()">💾 Quiz speichern</button>
<button class="btn btn-secondary" onclick="previewQuiz()">👁️ Vorschau</button>
<button class="btn btn-secondary" onclick="window.history.back()">← Zurück</button>
</div>
</div>
<script>
let questions = [];
function addQuestion() {
const questionId = Date.now();
questions.push({
id: questionId,
text: '',
answers: [
{ text: '', correct: true },
{ text: '', correct: false },
{ text: '', correct: false },
{ text: '', correct: false }
]
});
renderQuestions();
}
function removeQuestion(questionId) {
if (confirm('Frage wirklich löschen?')) {
questions = questions.filter(q => q.id !== questionId);
renderQuestions();
}
}
function toggleCorrectAnswer(questionId, answerIndex) {
const question = questions.find(q => q.id === questionId);
if (question) {
// Toggle correct answer
question.answers[answerIndex].correct = !question.answers[answerIndex].correct;
renderQuestions();
}
}
function updateQuestionText(questionId, text) {
const question = questions.find(q => q.id === questionId);
if (question) {
question.text = text;
}
}
function updateAnswerText(questionId, answerIndex, text) {
const question = questions.find(q => q.id === questionId);
if (question) {
question.answers[answerIndex].text = text;
}
}
function renderQuestions() {
const container = document.getElementById('questionsContainer');
container.innerHTML = '';
questions.forEach((question, qIndex) => {
const questionCard = document.createElement('div');
questionCard.className = 'question-card';
questionCard.innerHTML = `
<div class="question-header">
<span class="question-number">Frage ${qIndex + 1}</span>
<button class="btn btn-danger" onclick="removeQuestion(${question.id})">🗑️ Löschen</button>
</div>
<div class="form-group">
<label>Frage:</label>
<input type="text"
value="${question.text}"
onchange="updateQuestionText(${question.id}, this.value)"
placeholder="Gib hier deine Frage ein...">
</div>
<div class="form-group">
<label>Antwortmöglichkeiten (Markiere richtige Antworten):</label>
${question.answers.map((answer, aIndex) => `
<div class="answer-option ${answer.correct ? 'correct' : ''}">
<label class="checkbox-label">
<input type="checkbox"
${answer.correct ? 'checked' : ''}
onchange="toggleCorrectAnswer(${question.id}, ${aIndex})">
<span>${answer.correct ? '✓ Richtig' : 'Falsch'}</span>
</label>
<input type="text"
value="${answer.text}"
onchange="updateAnswerText(${question.id}, ${aIndex}, this.value)"
placeholder="Antwort ${aIndex + 1}">
</div>
`).join('')}
</div>
`;
container.appendChild(questionCard);
});
}
function saveQuiz() {
const title = document.getElementById('quizTitle').value;
const description = document.getElementById('quizDescription').value;
if (!title) {
alert('Bitte gib einen Titel für das Quiz ein!');
return;
}
if (questions.length === 0) {
alert('Bitte füge mindestens eine Frage hinzu!');
return;
}
// Validate questions
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
if (!q.text) {
alert(`Frage ${i + 1} hat keinen Text!`);
return;
}
const hasCorrectAnswer = q.answers.some(a => a.correct);
if (!hasCorrectAnswer) {
alert(`Frage ${i + 1} hat keine richtige Antwort markiert!`);
return;
}
const filledAnswers = q.answers.filter(a => a.text).length;
if (filledAnswers < 2) {
alert(`Frage ${i + 1} benötigt mindestens 2 Antworten!`);
return;
}
}
const quizData = {
type: 'quiz',
title,
description,
questions,
created: new Date().toISOString()
};
// Save to localStorage for now
const contentId = 'quiz_' + Date.now();
localStorage.setItem(contentId, JSON.stringify(quizData));
// Show success message
const successMsg = document.getElementById('successMessage');
successMsg.style.display = 'block';
setTimeout(() => {
successMsg.style.display = 'none';
}, 3000);
console.log('Quiz gespeichert:', quizData);
}
function previewQuiz() {
const title = document.getElementById('quizTitle').value;
if (!title || questions.length === 0) {
alert('Bitte fülle erst den Titel und mindestens eine Frage aus!');
return;
}
// Open preview in new window
const previewData = encodeURIComponent(JSON.stringify({
title,
description: document.getElementById('quizDescription').value,
questions
}));
window.open(`/h5p/player/quiz?data=${previewData}`, '_blank');
}
// Initialize with one question
addQuestion();
</script>
</body>
</html>

View File

@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timeline Editor - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 900px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.info-box {
background: #f0f9ff;
border-left: 4px solid #3b82f6;
padding: 16px;
margin-bottom: 24px;
border-radius: 4px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
input[type="text"], input[type="number"], textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s;
}
input[type="text"]:focus, input[type="number"]:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 80px;
}
.event-card {
background: #f9fafb;
border-left: 4px solid #667eea;
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.event-number {
font-weight: 700;
color: #667eea;
font-size: 16px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-group {
display: flex;
gap: 12px;
margin-top: 24px;
}
.add-event-btn {
width: 100%;
padding: 16px;
border: 2px dashed #9ca3af;
background: transparent;
color: #6b7280;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
}
.add-event-btn:hover {
border-color: #667eea;
color: #667eea;
background: #f9fafb;
}
.success-message {
background: #d1fae5;
border: 2px solid #10b981;
color: #065f46;
padding: 16px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
</style>
</head>
<body>
<div class="container">
<h1>
<span>📅</span>
Timeline Editor
</h1>
<div class="success-message" id="successMessage">
✅ Timeline erfolgreich gespeichert!
</div>
<div class="info-box">
<h3 style="color: #1e40af; margin-bottom: 8px; font-size: 14px;">💡 Wie funktioniert Timeline?</h3>
<p style="color: #475569; font-size: 14px; line-height: 1.6;">
Erstelle eine interaktive Zeitleiste mit historischen Ereignissen oder Meilensteinen.
Ideal für Geschichte, Biografie, Projektverläufe und mehr.
</p>
</div>
<div class="form-group">
<label for="title">Titel der Timeline</label>
<input type="text" id="title" placeholder="z.B. Wichtige Ereignisse des 20. Jahrhunderts">
</div>
<div class="form-group">
<label for="description">Beschreibung (optional)</label>
<textarea id="description" placeholder="Kurze Einführung zur Timeline..."></textarea>
</div>
<div id="eventsContainer"></div>
<button class="add-event-btn" onclick="addEvent()">
+ Neues Ereignis hinzufügen
</button>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveTimeline()">💾 Speichern</button>
<button class="btn btn-secondary" onclick="previewTimeline()">📅 Anzeigen</button>
<button class="btn btn-secondary" onclick="window.history.back()">← Zurück</button>
</div>
</div>
<script>
let events = [];
function addEvent() {
const eventId = Date.now();
events.push({
id: eventId,
year: new Date().getFullYear(),
title: '',
description: ''
});
renderEvents();
}
function removeEvent(eventId) {
if (confirm('Ereignis wirklich löschen?')) {
events = events.filter(e => e.id !== eventId);
renderEvents();
}
}
function updateEvent(eventId, field, value) {
const event = events.find(e => e.id === eventId);
if (event) {
event[field] = field === 'year' ? parseInt(value) || 0 : value;
}
}
function renderEvents() {
const container = document.getElementById('eventsContainer');
container.innerHTML = '';
// Sort by year
const sortedEvents = [...events].sort((a, b) => a.year - b.year);
sortedEvents.forEach((event, index) => {
const eventEl = document.createElement('div');
eventEl.className = 'event-card';
eventEl.innerHTML = `
<div class="event-header">
<span class="event-number">📍 Ereignis ${index + 1}</span>
<button class="btn btn-danger" style="padding: 6px 12px; font-size: 12px;" onclick="removeEvent(${event.id})">🗑️ Löschen</button>
</div>
<div class="form-group">
<label>Jahr</label>
<input type="number"
value="${event.year}"
onchange="updateEvent(${event.id}, 'year', this.value); renderEvents();"
placeholder="z.B. 1945">
</div>
<div class="form-group">
<label>Titel des Ereignisses</label>
<input type="text"
value="${event.title}"
onchange="updateEvent(${event.id}, 'title', this.value)"
placeholder="z.B. Ende des Zweiten Weltkriegs">
</div>
<div class="form-group">
<label>Beschreibung</label>
<textarea
onchange="updateEvent(${event.id}, 'description', this.value)"
placeholder="Kurze Beschreibung des Ereignisses...">${event.description}</textarea>
</div>
`;
container.appendChild(eventEl);
});
}
function saveTimeline() {
const title = document.getElementById('title').value;
const description = document.getElementById('description').value;
if (!title) {
alert('Bitte gib einen Titel ein!');
return;
}
if (events.length < 2) {
alert('Bitte füge mindestens 2 Ereignisse hinzu!');
return;
}
for (let i = 0; i < events.length; i++) {
if (!events[i].title) {
alert(`Ereignis ${i + 1} hat keinen Titel!`);
return;
}
if (!events[i].year) {
alert(`Ereignis ${i + 1} hat kein Jahr!`);
return;
}
}
const timelineData = {
type: 'timeline',
title,
description,
events: events.sort((a, b) => a.year - b.year),
created: new Date().toISOString()
};
const contentId = 'timeline_' + Date.now();
localStorage.setItem(contentId, JSON.stringify(timelineData));
const successMsg = document.getElementById('successMessage');
successMsg.style.display = 'block';
setTimeout(() => {
successMsg.style.display = 'none';
}, 3000);
console.log('Timeline gespeichert:', timelineData);
}
function previewTimeline() {
const title = document.getElementById('title').value;
if (!title || events.length < 2) {
alert('Bitte fülle erst den Titel und mindestens 2 Ereignisse aus!');
return;
}
const previewData = encodeURIComponent(JSON.stringify({
title,
description: document.getElementById('description').value,
events: events.sort((a, b) => a.year - b.year)
}));
window.open(`/h5p/player/timeline?data=${previewData}`, '_blank');
}
// Initialize with 3 events
addEvent();
addEvent();
addEvent();
</script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
/**
* Jest Configuration for H5P Service (ESM)
*/
export default {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
collectCoverageFrom: [
'server-simple.js',
'setup-h5p.js',
'!node_modules/**',
'!tests/**'
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
verbose: true,
testTimeout: 10000,
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
// ESM support
transform: {},
moduleFileExtensions: ['js', 'mjs']
};

24
h5p-service/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "breakpilot-h5p-service",
"version": "1.0.0",
"description": "H5P Interactive Content Service for BreakPilot - Simplified",
"main": "server-simple.js",
"type": "module",
"scripts": {
"start": "node server-simple.js",
"dev": "nodemon server-simple.js",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
"test:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js --ci --coverage --maxWorkers=2"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5"
},
"devDependencies": {
"nodemon": "^3.0.3",
"jest": "^29.7.0",
"@jest/globals": "^29.7.0",
"supertest": "^6.3.4"
}
}

View File

@@ -0,0 +1,358 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Course Presentation - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1f2937;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.presentation-container {
max-width: 1200px;
width: 100%;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 24px 32px;
text-align: center;
}
.header h1 {
font-size: 28px;
margin-bottom: 8px;
}
.header p {
opacity: 0.9;
font-size: 14px;
}
.slide-viewer {
position: relative;
min-height: 500px;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
}
.slide {
display: none;
width: 100%;
animation: slideIn 0.4s ease-out;
}
.slide.active {
display: block;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.slide-title {
font-size: 36px;
font-weight: 700;
margin-bottom: 24px;
color: #1f2937;
}
.slide-content {
font-size: 18px;
line-height: 1.8;
color: #4b5563;
margin-bottom: 24px;
white-space: pre-wrap;
}
.slide-image {
max-width: 100%;
border-radius: 12px;
margin-top: 24px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 32px;
background: #f9fafb;
border-top: 2px solid #e5e7eb;
}
.nav-buttons {
display: flex;
gap: 12px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-primary:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
.progress-info {
font-size: 14px;
color: #6b7280;
font-weight: 600;
}
.progress-bar {
height: 4px;
background: #e5e7eb;
position: relative;
margin-bottom: 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s;
}
.thumbnails {
display: none;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
padding: 24px;
background: #f9fafb;
border-top: 2px solid #e5e7eb;
}
.thumbnails.show {
display: grid;
}
.thumbnail {
background: white;
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
}
.thumbnail:hover {
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.thumbnail.active {
border-color: #667eea;
background: #f0f9ff;
}
.thumbnail-number {
font-size: 12px;
font-weight: 700;
color: #667eea;
margin-bottom: 8px;
}
.thumbnail-title {
font-size: 14px;
font-weight: 600;
color: #1f2937;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<div class="presentation-container">
<div class="header">
<h1 id="presentationTitle"></h1>
<p id="presentationDescription"></p>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="slide-viewer" id="slideViewer"></div>
<div class="controls">
<div class="nav-buttons">
<button class="btn btn-primary" id="prevBtn" onclick="previousSlide()">
← Zurück
</button>
<button class="btn btn-primary" id="nextBtn" onclick="nextSlide()">
Weiter →
</button>
</div>
<div class="progress-info">
<span id="currentSlide">1</span> / <span id="totalSlides">1</span>
</div>
<button class="btn btn-secondary" onclick="toggleThumbnails()">
📋 Übersicht
</button>
</div>
<div class="thumbnails" id="thumbnails"></div>
</div>
<script>
let data = null;
let currentSlideIndex = 0;
function loadContent() {
const urlParams = new URLSearchParams(window.location.search);
const dataParam = urlParams.get('data');
if (dataParam) {
try {
data = JSON.parse(decodeURIComponent(dataParam));
} catch (e) {
alert('Fehler beim Laden!');
return;
}
}
if (!data) {
alert('Keine Daten gefunden!');
return;
}
document.getElementById('presentationTitle').textContent = data.title;
document.getElementById('presentationDescription').textContent = data.description || '';
document.getElementById('totalSlides').textContent = data.slides.length;
renderSlides();
renderThumbnails();
updateProgress();
updateNavButtons();
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') previousSlide();
if (e.key === 'ArrowRight') nextSlide();
if (e.key === 'Escape') hideThumbnails();
});
}
function renderSlides() {
const viewer = document.getElementById('slideViewer');
viewer.innerHTML = '';
data.slides.forEach((slide, index) => {
const slideEl = document.createElement('div');
slideEl.className = 'slide';
if (index === 0) slideEl.classList.add('active');
slideEl.style.backgroundColor = slide.backgroundColor;
slideEl.innerHTML = `
<div class="slide-title">${slide.title}</div>
<div class="slide-content">${slide.content}</div>
${slide.imageUrl ? `<img src="${slide.imageUrl}" class="slide-image" alt="${slide.title}">` : ''}
`;
viewer.appendChild(slideEl);
});
}
function renderThumbnails() {
const container = document.getElementById('thumbnails');
container.innerHTML = '';
data.slides.forEach((slide, index) => {
const thumb = document.createElement('div');
thumb.className = 'thumbnail';
if (index === 0) thumb.classList.add('active');
thumb.innerHTML = `
<div class="thumbnail-number">Folie ${index + 1}</div>
<div class="thumbnail-title">${slide.title}</div>
`;
thumb.onclick = () => goToSlide(index);
container.appendChild(thumb);
});
}
function goToSlide(index) {
if (index < 0 || index >= data.slides.length) return;
// Hide current slide
const slides = document.querySelectorAll('.slide');
slides[currentSlideIndex].classList.remove('active');
// Update thumbnails
const thumbs = document.querySelectorAll('.thumbnail');
thumbs[currentSlideIndex].classList.remove('active');
// Show new slide
currentSlideIndex = index;
slides[currentSlideIndex].classList.add('active');
thumbs[currentSlideIndex].classList.add('active');
updateProgress();
updateNavButtons();
hideThumbnails();
}
function nextSlide() {
goToSlide(currentSlideIndex + 1);
}
function previousSlide() {
goToSlide(currentSlideIndex - 1);
}
function updateProgress() {
document.getElementById('currentSlide').textContent = currentSlideIndex + 1;
const progress = ((currentSlideIndex + 1) / data.slides.length) * 100;
document.getElementById('progressFill').style.width = progress + '%';
}
function updateNavButtons() {
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
prevBtn.disabled = currentSlideIndex === 0;
nextBtn.disabled = currentSlideIndex === data.slides.length - 1;
}
function toggleThumbnails() {
const thumbnails = document.getElementById('thumbnails');
thumbnails.classList.toggle('show');
}
function hideThumbnails() {
const thumbnails = document.getElementById('thumbnails');
thumbnails.classList.remove('show');
}
loadContent();
</script>
</body>
</html>

View File

@@ -0,0 +1,439 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drag and Drop - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
min-height: 100vh;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 900px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 12px;
font-size: 28px;
}
.question {
color: #6b7280;
margin-bottom: 32px;
font-size: 16px;
line-height: 1.6;
}
.game-area {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
margin-bottom: 24px;
}
.zones-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.drop-zone {
background: #f9fafb;
border: 3px dashed #d1d5db;
border-radius: 12px;
padding: 20px;
min-height: 120px;
transition: all 0.2s;
}
.drop-zone.drag-over {
background: #e0e7ff;
border-color: #667eea;
}
.drop-zone.correct {
background: #d1fae5;
border-color: #10b981;
border-style: solid;
}
.zone-title {
font-weight: 700;
color: #374151;
margin-bottom: 12px;
font-size: 16px;
}
.zone-items {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 40px;
}
.draggables-container {
background: #fef3c7;
border: 2px solid #f59e0b;
border-radius: 12px;
padding: 20px;
}
.draggables-title {
font-weight: 700;
color: #92400e;
margin-bottom: 12px;
font-size: 14px;
text-transform: uppercase;
}
.draggables-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.draggable {
background: white;
border: 2px solid #f59e0b;
border-radius: 8px;
padding: 12px 16px;
cursor: move;
user-select: none;
transition: all 0.2s;
font-weight: 600;
color: #1f2937;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.draggable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.draggable.dragging {
opacity: 0.5;
cursor: grabbing;
}
.draggable.correct {
border-color: #10b981;
background: #d1fae5;
color: #065f46;
cursor: default;
}
.draggable.incorrect {
border-color: #ef4444;
background: #fee2e2;
color: #991b1b;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-primary:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-group {
display: flex;
gap: 12px;
margin-top: 24px;
}
.results {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
padding: 32px;
text-align: center;
margin-bottom: 24px;
display: none;
}
.results h2 {
font-size: 32px;
margin-bottom: 16px;
}
.results .score {
font-size: 64px;
font-weight: 700;
margin: 20px 0;
}
.feedback {
background: #f0f9ff;
border-left: 4px solid #3b82f6;
padding: 16px;
margin-bottom: 24px;
border-radius: 4px;
display: none;
}
.feedback.show {
display: block;
}
.feedback.correct {
background: #d1fae5;
border-color: #10b981;
color: #065f46;
}
.feedback.incorrect {
background: #fee2e2;
border-color: #ef4444;
color: #991b1b;
}
@media (max-width: 768px) {
.game-area {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<h1 id="title"></h1>
<p class="question" id="question"></p>
<div class="results" id="results">
<h2>🎉 Geschafft!</h2>
<div class="score" id="scoreDisplay"></div>
<p id="resultMessage"></p>
</div>
<div class="feedback" id="feedback"></div>
<div class="game-area">
<div class="zones-container" id="zonesContainer"></div>
<div class="draggables-container">
<div class="draggables-title">🔲 Ziehe die Elemente</div>
<div class="draggables-items" id="draggablesContainer"></div>
</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" id="checkBtn" onclick="checkAnswers()">
Antworten überprüfen
</button>
<button class="btn btn-secondary" id="retryBtn" onclick="retry()" style="display: none;">
Nochmal versuchen
</button>
</div>
</div>
<script>
let data = null;
let isChecked = false;
function loadContent() {
const urlParams = new URLSearchParams(window.location.search);
const dataParam = urlParams.get('data');
if (dataParam) {
try {
data = JSON.parse(decodeURIComponent(dataParam));
} catch (e) {
alert('Fehler beim Laden!');
return;
}
}
if (!data) {
alert('Keine Daten gefunden!');
return;
}
document.getElementById('title').textContent = data.title;
document.getElementById('question').textContent = data.question || 'Ziehe die Elemente in die richtigen Zonen.';
renderZones();
renderDraggables();
setupDragAndDrop();
}
function renderZones() {
const container = document.getElementById('zonesContainer');
container.innerHTML = '';
data.zones.forEach((zone) => {
const zoneEl = document.createElement('div');
zoneEl.className = 'drop-zone';
zoneEl.dataset.zoneId = zone.id;
zoneEl.innerHTML = `
<div class="zone-title">${zone.name}</div>
<div class="zone-items" data-zone-id="${zone.id}"></div>
`;
container.appendChild(zoneEl);
});
}
function renderDraggables() {
const container = document.getElementById('draggablesContainer');
container.innerHTML = '';
// Shuffle draggables
const shuffled = [...data.draggables].sort(() => Math.random() - 0.5);
shuffled.forEach((draggable) => {
const draggableEl = document.createElement('div');
draggableEl.className = 'draggable';
draggableEl.draggable = true;
draggableEl.dataset.draggableId = draggable.id;
draggableEl.dataset.correctZoneId = draggable.correctZoneId;
draggableEl.textContent = draggable.text;
container.appendChild(draggableEl);
});
}
function setupDragAndDrop() {
// Draggable elements
document.querySelectorAll('.draggable').forEach(draggable => {
draggable.addEventListener('dragstart', (e) => {
if (isChecked) return;
draggable.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', draggable.outerHTML);
});
draggable.addEventListener('dragend', (e) => {
draggable.classList.remove('dragging');
});
});
// Drop zones
document.querySelectorAll('.zone-items').forEach(zone => {
zone.addEventListener('dragover', (e) => {
if (isChecked) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
zone.closest('.drop-zone').classList.add('drag-over');
});
zone.addEventListener('dragleave', (e) => {
zone.closest('.drop-zone').classList.remove('drag-over');
});
zone.addEventListener('drop', (e) => {
if (isChecked) return;
e.preventDefault();
zone.closest('.drop-zone').classList.remove('drag-over');
const dragging = document.querySelector('.dragging');
if (dragging) {
zone.appendChild(dragging);
}
});
});
// Original container
const draggablesContainer = document.getElementById('draggablesContainer');
draggablesContainer.addEventListener('dragover', (e) => {
if (isChecked) return;
e.preventDefault();
});
draggablesContainer.addEventListener('drop', (e) => {
if (isChecked) return;
e.preventDefault();
const dragging = document.querySelector('.dragging');
if (dragging) {
draggablesContainer.appendChild(dragging);
}
});
}
function checkAnswers() {
if (isChecked) return;
isChecked = true;
let correct = 0;
const total = data.draggables.length;
data.zones.forEach(zone => {
const zoneItems = document.querySelector(`.zone-items[data-zone-id="${zone.id}"]`);
const draggablesInZone = zoneItems.querySelectorAll('.draggable');
draggablesInZone.forEach(draggable => {
const correctZoneId = parseInt(draggable.dataset.correctZoneId);
draggable.draggable = false;
if (correctZoneId === zone.id) {
draggable.classList.add('correct');
zoneItems.closest('.drop-zone').classList.add('correct');
correct++;
} else {
draggable.classList.add('incorrect');
}
});
});
// Check items still in draggables container
document.getElementById('draggablesContainer').querySelectorAll('.draggable').forEach(draggable => {
draggable.classList.add('incorrect');
draggable.draggable = false;
});
const percentage = Math.round((correct / total) * 100);
// Show feedback
const feedback = document.getElementById('feedback');
if (correct === total) {
feedback.className = 'feedback correct show';
feedback.textContent = `🎉 Perfekt! Alle ${total} Elemente richtig zugeordnet!`;
setTimeout(() => {
showResults(correct, total);
}, 1500);
} else {
feedback.className = 'feedback incorrect show';
feedback.textContent = `Du hast ${correct} von ${total} Elementen richtig zugeordnet (${percentage}%).`;
}
document.getElementById('checkBtn').style.display = 'none';
document.getElementById('retryBtn').style.display = 'inline-block';
}
function showResults(correct, total) {
const percentage = Math.round((correct / total) * 100);
document.getElementById('scoreDisplay').textContent = `${correct} / ${total}`;
document.getElementById('resultMessage').textContent = `${percentage}% richtig - Sehr gut!`;
document.getElementById('results').style.display = 'block';
}
function retry() {
isChecked = false;
// Move all draggables back
const draggablesContainer = document.getElementById('draggablesContainer');
document.querySelectorAll('.draggable').forEach(draggable => {
draggable.classList.remove('correct', 'incorrect');
draggable.draggable = true;
draggablesContainer.appendChild(draggable);
});
// Clear zone correct state
document.querySelectorAll('.drop-zone').forEach(zone => {
zone.classList.remove('correct');
});
document.getElementById('feedback').classList.remove('show');
document.getElementById('results').style.display = 'none';
document.getElementById('checkBtn').style.display = 'inline-block';
document.getElementById('retryBtn').style.display = 'none';
}
loadContent();
</script>
</body>
</html>

View File

@@ -0,0 +1,314 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fill in the Blanks Player - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
min-height: 100vh;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 800px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 12px;
font-size: 28px;
}
.instructions {
color: #6b7280;
margin-bottom: 32px;
font-size: 16px;
}
.text-container {
font-size: 18px;
line-height: 2;
color: #1f2937;
margin-bottom: 32px;
}
.blank-input {
display: inline-block;
border: none;
border-bottom: 2px solid #9ca3af;
background: #f9fafb;
padding: 4px 12px;
font-size: 18px;
font-family: inherit;
min-width: 120px;
text-align: center;
transition: all 0.2s;
}
.blank-input:focus {
outline: none;
border-bottom-color: #667eea;
background: white;
}
.blank-input.correct {
border-bottom-color: #10b981;
background: #d1fae5;
color: #065f46;
}
.blank-input.incorrect {
border-bottom-color: #ef4444;
background: #fee2e2;
color: #991b1b;
}
.blank-input:disabled {
cursor: not-allowed;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-primary:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-group {
display: flex;
gap: 12px;
margin-top: 24px;
}
.results {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
padding: 32px;
text-align: center;
margin-bottom: 24px;
display: none;
}
.results h2 {
font-size: 32px;
margin-bottom: 16px;
}
.results .score {
font-size: 64px;
font-weight: 700;
margin: 20px 0;
}
.feedback {
background: #f0f9ff;
border-left: 4px solid #3b82f6;
padding: 16px;
margin-bottom: 24px;
border-radius: 4px;
display: none;
}
.feedback.show {
display: block;
}
.feedback.correct {
background: #d1fae5;
border-color: #10b981;
color: #065f46;
}
.feedback.incorrect {
background: #fee2e2;
border-color: #ef4444;
color: #991b1b;
}
</style>
</head>
<body>
<div class="container">
<h1 id="title"></h1>
<p class="instructions" id="instructions"></p>
<div class="results" id="results">
<h2>🎉 Fertig!</h2>
<div class="score" id="scoreDisplay"></div>
<p id="resultMessage"></p>
</div>
<div class="feedback" id="feedback"></div>
<div class="text-container" id="textContainer"></div>
<div class="btn-group">
<button class="btn btn-primary" id="checkBtn" onclick="checkAnswers()">
Antworten überprüfen
</button>
<button class="btn btn-secondary" id="showBtn" onclick="showSolution()" style="display: none;">
Lösung anzeigen
</button>
<button class="btn btn-secondary" id="retryBtn" onclick="retry()" style="display: none;">
Nochmal versuchen
</button>
</div>
</div>
<script>
let data = null;
let userAnswers = [];
let isChecked = false;
function loadContent() {
const urlParams = new URLSearchParams(window.location.search);
const dataParam = urlParams.get('data');
if (dataParam) {
try {
data = JSON.parse(decodeURIComponent(dataParam));
} catch (e) {
alert('Fehler beim Laden!');
return;
}
}
if (!data) {
alert('Keine Daten gefunden!');
return;
}
document.getElementById('title').textContent = data.title;
document.getElementById('instructions').textContent = data.instructions || 'Fülle die Lücken aus.';
renderText();
}
function renderText() {
const container = document.getElementById('textContainer');
let text = data.text;
let blankIndex = 0;
// Replace *word* with input fields
const html = text.replace(/\*([^*]+)\*/g, (match, word) => {
const inputId = `blank_${blankIndex}`;
blankIndex++;
return `<input type="text" class="blank-input" id="${inputId}" data-answer="${word}" />`;
});
container.innerHTML = html;
// Initialize userAnswers array
userAnswers = new Array(blankIndex).fill('');
// Add event listeners
document.querySelectorAll('.blank-input').forEach((input, index) => {
input.addEventListener('input', (e) => {
userAnswers[index] = e.target.value;
});
});
}
function checkAnswers() {
if (isChecked) return;
isChecked = true;
let correct = 0;
const inputs = document.querySelectorAll('.blank-input');
inputs.forEach((input) => {
const correctAnswer = input.dataset.answer.toLowerCase().trim();
const userAnswer = input.value.toLowerCase().trim();
input.disabled = true;
if (userAnswer === correctAnswer) {
input.classList.add('correct');
correct++;
} else {
input.classList.add('incorrect');
}
});
const total = inputs.length;
const percentage = Math.round((correct / total) * 100);
// Show feedback
const feedback = document.getElementById('feedback');
if (correct === total) {
feedback.className = 'feedback correct show';
feedback.textContent = `🎉 Perfekt! Alle ${total} Lücken richtig ausgefüllt!`;
} else {
feedback.className = 'feedback incorrect show';
feedback.textContent = `Du hast ${correct} von ${total} Lücken richtig ausgefüllt (${percentage}%).`;
}
// Update buttons
document.getElementById('checkBtn').style.display = 'none';
document.getElementById('showBtn').style.display = 'inline-block';
document.getElementById('retryBtn').style.display = 'inline-block';
// Show results
if (correct === total) {
setTimeout(() => {
showResults(correct, total);
}, 1500);
}
}
function showSolution() {
const inputs = document.querySelectorAll('.blank-input');
inputs.forEach((input) => {
input.value = input.dataset.answer;
input.classList.remove('incorrect');
input.classList.add('correct');
});
const feedback = document.getElementById('feedback');
feedback.className = 'feedback show';
feedback.textContent = '💡 Hier ist die vollständige Lösung.';
}
function retry() {
isChecked = false;
userAnswers = [];
const inputs = document.querySelectorAll('.blank-input');
inputs.forEach((input) => {
input.value = '';
input.disabled = false;
input.classList.remove('correct', 'incorrect');
});
document.getElementById('feedback').classList.remove('show');
document.getElementById('results').style.display = 'none';
document.getElementById('checkBtn').style.display = 'inline-block';
document.getElementById('showBtn').style.display = 'none';
document.getElementById('retryBtn').style.display = 'none';
}
function showResults(correct, total) {
const percentage = Math.round((correct / total) * 100);
document.getElementById('scoreDisplay').textContent = `${correct} / ${total}`;
document.getElementById('resultMessage').textContent = `${percentage}% richtig!`;
document.getElementById('results').style.display = 'block';
}
loadContent();
</script>
</body>
</html>

View File

@@ -0,0 +1,425 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive Video - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
min-height: 100vh;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 12px;
font-size: 28px;
}
.description {
color: #6b7280;
margin-bottom: 24px;
font-size: 16px;
line-height: 1.6;
}
.video-wrapper {
position: relative;
background: #000;
border-radius: 12px;
overflow: hidden;
aspect-ratio: 16/9;
margin-bottom: 24px;
}
.video-wrapper iframe,
.video-wrapper video {
width: 100%;
height: 100%;
border: none;
}
.interaction-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 100;
}
.interaction-overlay.show {
display: flex;
}
.interaction-content {
background: white;
border-radius: 12px;
padding: 32px;
max-width: 600px;
width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.interaction-icon {
font-size: 48px;
text-align: center;
margin-bottom: 16px;
}
.interaction-title {
font-size: 24px;
font-weight: 700;
color: #1f2937;
margin-bottom: 16px;
text-align: center;
}
.interaction-text {
color: #4b5563;
font-size: 16px;
line-height: 1.6;
margin-bottom: 24px;
}
.interaction-link {
display: block;
padding: 12px 24px;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 8px;
text-align: center;
font-weight: 600;
margin-bottom: 12px;
}
.interaction-link:hover {
background: #5568d3;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
width: 100%;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.timeline {
background: #f9fafb;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.timeline-title {
font-weight: 700;
color: #374151;
margin-bottom: 12px;
font-size: 14px;
}
.timeline-markers {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.timeline-marker {
background: #fef3c7;
border: 2px solid #f59e0b;
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: #92400e;
cursor: pointer;
transition: all 0.2s;
}
.timeline-marker:hover {
background: #fde68a;
transform: translateY(-2px);
}
.timeline-marker.completed {
background: #d1fae5;
border-color: #10b981;
color: #065f46;
}
.controls {
text-align: center;
color: #6b7280;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<h1 id="title"></h1>
<p class="description" id="description"></p>
<div class="video-wrapper">
<div id="videoContainer"></div>
<div class="interaction-overlay" id="interactionOverlay">
<div class="interaction-content">
<div class="interaction-icon" id="interactionIcon"></div>
<div class="interaction-title" id="interactionTitle"></div>
<div class="interaction-text" id="interactionText"></div>
<div id="interactionExtra"></div>
<button class="btn btn-primary" onclick="closeInteraction()">Weiter</button>
</div>
</div>
</div>
<div class="timeline">
<div class="timeline-title">📌 Interaktive Elemente im Video:</div>
<div class="timeline-markers" id="timelineMarkers"></div>
</div>
<div class="controls">
Das Video pausiert automatisch bei interaktiven Elementen
</div>
</div>
<script src="https://www.youtube.com/iframe_api"></script>
<script src="https://player.vimeo.com/api/player.js"></script>
<script>
let data = null;
let player = null;
let playerType = null;
let currentTime = 0;
let completedInteractions = new Set();
let checkInterval = null;
function loadContent() {
const urlParams = new URLSearchParams(window.location.search);
const dataParam = urlParams.get('data');
if (dataParam) {
try {
data = JSON.parse(decodeURIComponent(dataParam));
} catch (e) {
alert('Fehler beim Laden!');
return;
}
}
if (!data) {
alert('Keine Daten gefunden!');
return;
}
document.getElementById('title').textContent = data.title;
document.getElementById('description').textContent = data.description || '';
renderTimeline();
embedVideo();
}
function embedVideo() {
const container = document.getElementById('videoContainer');
const url = data.videoUrl;
// YouTube
if (url.includes('youtube.com') || url.includes('youtu.be')) {
const videoId = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&]+)/)?.[1];
if (videoId) {
playerType = 'youtube';
container.innerHTML = `<div id="ytPlayer"></div>`;
// YouTube API will be loaded via script tag
window.onYouTubeIframeAPIReady = function() {
player = new YT.Player('ytPlayer', {
videoId: videoId,
width: '100%',
height: '100%',
playerVars: {
autoplay: 0,
modestbranding: 1,
rel: 0
},
events: {
onReady: function() {
startTimeTracking();
},
onStateChange: function(event) {
if (event.data === YT.PlayerState.PLAYING) {
startTimeTracking();
}
}
}
});
};
}
}
// Vimeo
else if (url.includes('vimeo.com')) {
const videoId = url.match(/vimeo\.com\/(\d+)/)?.[1];
if (videoId) {
playerType = 'vimeo';
container.innerHTML = `<iframe id="vimeoPlayer" src="https://player.vimeo.com/video/${videoId}" allowfullscreen></iframe>`;
setTimeout(() => {
player = new Vimeo.Player('vimeoPlayer');
player.on('timeupdate', function(data) {
currentTime = data.seconds;
checkInteractions();
});
}, 500);
}
}
// Direct MP4
else if (url.endsWith('.mp4')) {
playerType = 'html5';
container.innerHTML = `<video id="html5Player" controls style="width: 100%; height: 100%;"><source src="${url}" type="video/mp4"></video>`;
const video = document.getElementById('html5Player');
video.addEventListener('timeupdate', function() {
currentTime = video.currentTime;
checkInteractions();
});
player = video;
}
}
function startTimeTracking() {
if (checkInterval) clearInterval(checkInterval);
checkInterval = setInterval(() => {
if (playerType === 'youtube' && player && player.getCurrentTime) {
currentTime = player.getCurrentTime();
checkInteractions();
}
}, 500);
}
function checkInteractions() {
data.interactions.forEach((interaction, index) => {
const interactionTime = timeToSeconds(interaction.time);
// Check if we're at this interaction time (within 1 second window)
if (Math.abs(currentTime - interactionTime) < 0.5 && !completedInteractions.has(index)) {
showInteraction(interaction, index);
}
});
}
function showInteraction(interaction, index) {
// Pause video
if (playerType === 'youtube' && player && player.pauseVideo) {
player.pauseVideo();
} else if (playerType === 'vimeo' && player && player.pause) {
player.pause();
} else if (playerType === 'html5' && player && player.pause) {
player.pause();
}
// Show overlay
const overlay = document.getElementById('interactionOverlay');
const icon = document.getElementById('interactionIcon');
const title = document.getElementById('interactionTitle');
const text = document.getElementById('interactionText');
const extra = document.getElementById('interactionExtra');
// Set icon based on type
switch (interaction.type) {
case 'question':
icon.textContent = '❓';
break;
case 'info':
icon.textContent = '';
break;
case 'link':
icon.textContent = '🔗';
break;
}
title.textContent = interaction.title;
text.textContent = interaction.content;
// Add link if type is link
if (interaction.type === 'link') {
extra.innerHTML = `<a href="${interaction.content}" target="_blank" class="interaction-link">🔗 Link öffnen</a>`;
} else {
extra.innerHTML = '';
}
overlay.classList.add('show');
completedInteractions.add(index);
// Update timeline
renderTimeline();
}
function closeInteraction() {
const overlay = document.getElementById('interactionOverlay');
overlay.classList.remove('show');
// Resume video
if (playerType === 'youtube' && player && player.playVideo) {
player.playVideo();
} else if (playerType === 'vimeo' && player && player.play) {
player.play();
} else if (playerType === 'html5' && player && player.play) {
player.play();
}
}
function renderTimeline() {
const container = document.getElementById('timelineMarkers');
container.innerHTML = '';
data.interactions.forEach((interaction, index) => {
const marker = document.createElement('div');
marker.className = 'timeline-marker';
if (completedInteractions.has(index)) {
marker.classList.add('completed');
}
const iconMap = {
question: '❓',
info: '',
link: '🔗'
};
marker.textContent = `${iconMap[interaction.type]} ${interaction.time} - ${interaction.title}`;
marker.onclick = () => seekTo(interaction.time);
container.appendChild(marker);
});
}
function seekTo(timeStr) {
const seconds = timeToSeconds(timeStr);
if (playerType === 'youtube' && player && player.seekTo) {
player.seekTo(seconds, true);
} else if (playerType === 'vimeo' && player && player.setCurrentTime) {
player.setCurrentTime(seconds);
} else if (playerType === 'html5' && player) {
player.currentTime = seconds;
}
}
function timeToSeconds(timeStr) {
const parts = timeStr.split(':');
const minutes = parseInt(parts[0]) || 0;
const seconds = parseInt(parts[1]) || 0;
return minutes * 60 + seconds;
}
loadContent();
</script>
</body>
</html>

View File

@@ -0,0 +1,340 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Memory Game - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 8px;
font-size: 28px;
}
.stats {
display: flex;
gap: 24px;
margin-top: 16px;
}
.stat {
flex: 1;
background: #f9fafb;
padding: 12px 16px;
border-radius: 8px;
text-align: center;
}
.stat-label {
color: #6b7280;
font-size: 12px;
text-transform: uppercase;
margin-bottom: 4px;
}
.stat-value {
color: #1f2937;
font-size: 24px;
font-weight: 700;
}
.game-board {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.card {
aspect-ratio: 1;
background: white;
border-radius: 12px;
cursor: pointer;
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.card.flipped {
transform: rotateY(180deg);
}
.card.matched {
opacity: 0.5;
cursor: default;
}
.card-face {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
text-align: center;
word-wrap: break-word;
}
.card-front {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 48px;
}
.card-back {
background: white;
color: #1f2937;
transform: rotateY(180deg);
border: 3px solid #e5e7eb;
}
.card.matched .card-back {
background: #d1fae5;
border-color: #10b981;
color: #065f46;
}
.results {
background: white;
border-radius: 12px;
padding: 32px;
text-align: center;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
display: none;
}
.results h2 {
color: #333;
font-size: 32px;
margin-bottom: 16px;
}
.results .trophy {
font-size: 80px;
margin: 20px 0;
}
.results .time {
font-size: 48px;
font-weight: 700;
color: #667eea;
margin: 20px 0;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
background: #667eea;
color: white;
margin-top: 20px;
}
.btn:hover {
background: #5568d3;
transform: translateY(-2px);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 id="title"></h1>
<p id="description" style="color: #6b7280; margin-top: 8px;"></p>
<div class="stats">
<div class="stat">
<div class="stat-label">Züge</div>
<div class="stat-value" id="moves">0</div>
</div>
<div class="stat">
<div class="stat-label">Gefunden</div>
<div class="stat-value"><span id="matched">0</span> / <span id="total">0</span></div>
</div>
<div class="stat">
<div class="stat-label">Zeit</div>
<div class="stat-value" id="time">0:00</div>
</div>
</div>
</div>
<div class="game-board" id="gameBoard"></div>
<div class="results" id="results">
<div class="trophy">🏆</div>
<h2>Geschafft!</h2>
<p style="color: #6b7280; margin: 12px 0;">Du hast alle Paare gefunden!</p>
<div class="time" id="finalTime"></div>
<p style="color: #6b7280;"><span id="finalMoves"></span> Züge</p>
<button class="btn" onclick="restart()">🔄 Nochmal spielen</button>
</div>
</div>
<script>
let data = null;
let cards = [];
let flippedCards = [];
let matchedPairs = 0;
let moves = 0;
let startTime = null;
let timerInterval = null;
let canFlip = true;
function loadGame() {
const urlParams = new URLSearchParams(window.location.search);
const dataParam = urlParams.get('data');
if (dataParam) {
try {
data = JSON.parse(decodeURIComponent(dataParam));
} catch (e) {
alert('Fehler beim Laden!');
return;
}
}
if (!data) {
alert('Keine Daten gefunden!');
return;
}
document.getElementById('title').textContent = data.title;
document.getElementById('description').textContent = data.description || '';
document.getElementById('total').textContent = data.pairs.length;
createCards();
renderBoard();
startTimer();
}
function createCards() {
// Create card array (2 cards per pair)
cards = [];
data.pairs.forEach((pair, index) => {
cards.push({ id: `${index}-1`, pairId: index, text: pair.card1 });
cards.push({ id: `${index}-2`, pairId: index, text: pair.card2 });
});
// Shuffle cards
cards = cards.sort(() => Math.random() - 0.5);
}
function renderBoard() {
const board = document.getElementById('gameBoard');
board.innerHTML = '';
cards.forEach((card, index) => {
const cardEl = document.createElement('div');
cardEl.className = 'card';
cardEl.dataset.cardId = card.id;
cardEl.dataset.pairId = card.pairId;
cardEl.innerHTML = `
<div class="card-face card-front">🧠</div>
<div class="card-face card-back">${card.text}</div>
`;
cardEl.addEventListener('click', () => flipCard(cardEl, card));
board.appendChild(cardEl);
});
}
function flipCard(cardEl, card) {
if (!canFlip) return;
if (cardEl.classList.contains('flipped')) return;
if (cardEl.classList.contains('matched')) return;
cardEl.classList.add('flipped');
flippedCards.push({ element: cardEl, card });
if (flippedCards.length === 2) {
canFlip = false;
moves++;
document.getElementById('moves').textContent = moves;
checkMatch();
}
}
function checkMatch() {
const [first, second] = flippedCards;
if (first.card.pairId === second.card.pairId) {
// Match!
setTimeout(() => {
first.element.classList.add('matched');
second.element.classList.add('matched');
flippedCards = [];
canFlip = true;
matchedPairs++;
document.getElementById('matched').textContent = matchedPairs;
if (matchedPairs === data.pairs.length) {
endGame();
}
}, 500);
} else {
// No match
setTimeout(() => {
first.element.classList.remove('flipped');
second.element.classList.remove('flipped');
flippedCards = [];
canFlip = true;
}, 1000);
}
}
function startTimer() {
startTime = Date.now();
timerInterval = setInterval(updateTimer, 1000);
}
function updateTimer() {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
document.getElementById('time').textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function endGame() {
clearInterval(timerInterval);
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
document.getElementById('finalTime').textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
document.getElementById('finalMoves').textContent = moves;
setTimeout(() => {
document.getElementById('results').style.display = 'block';
document.querySelector('.game-board').style.display = 'none';
}, 1000);
}
function restart() {
matchedPairs = 0;
moves = 0;
flippedCards = [];
canFlip = true;
document.getElementById('moves').textContent = 0;
document.getElementById('matched').textContent = 0;
document.getElementById('results').style.display = 'none';
document.querySelector('.game-board').style.display = 'grid';
createCards();
renderBoard();
clearInterval(timerInterval);
startTimer();
}
loadGame();
</script>
</body>
</html>

View File

@@ -0,0 +1,380 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quiz Player - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
min-height: 100vh;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 800px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 12px;
font-size: 28px;
}
.description {
color: #6b7280;
margin-bottom: 32px;
line-height: 1.6;
}
.question-card {
background: #f9fafb;
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
}
.question-number {
color: #667eea;
font-weight: 600;
margin-bottom: 12px;
font-size: 14px;
}
.question-text {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin-bottom: 20px;
}
.answer {
background: white;
border: 2px solid #e5e7eb;
border-radius: 6px;
padding: 14px 16px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 12px;
}
.answer:hover {
border-color: #9ca3af;
}
.answer.selected {
border-color: #667eea;
background: #f0f4ff;
}
.answer.correct {
border-color: #10b981;
background: #d1fae5;
}
.answer.incorrect {
border-color: #ef4444;
background: #fee2e2;
}
.answer .checkbox {
width: 20px;
height: 20px;
border: 2px solid #9ca3af;
border-radius: 4px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.answer.selected .checkbox {
background: #667eea;
border-color: #667eea;
color: white;
}
.answer.correct .checkbox {
background: #10b981;
border-color: #10b981;
color: white;
}
.answer.incorrect .checkbox {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.answer-text {
flex: 1;
}
.feedback {
margin-top: 12px;
padding: 12px;
border-radius: 6px;
font-size: 14px;
display: none;
}
.feedback.correct {
background: #d1fae5;
color: #065f46;
border: 1px solid #10b981;
display: block;
}
.feedback.incorrect {
background: #fee2e2;
color: #991b1b;
border: 1px solid #ef4444;
display: block;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-primary:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.btn-group {
display: flex;
gap: 12px;
margin-top: 24px;
}
.results {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
padding: 32px;
text-align: center;
margin-bottom: 24px;
display: none;
}
.results h2 {
font-size: 32px;
margin-bottom: 16px;
}
.results .score {
font-size: 64px;
font-weight: 700;
margin: 20px 0;
}
.results .percentage {
font-size: 24px;
opacity: 0.9;
}
.progress-bar {
background: #e5e7eb;
height: 8px;
border-radius: 4px;
margin-bottom: 24px;
overflow: hidden;
}
.progress-fill {
background: #667eea;
height: 100%;
transition: width 0.3s;
}
</style>
</head>
<body>
<div class="container">
<div class="progress-bar">
<div class="progress-fill" id="progressFill" style="width: 0%"></div>
</div>
<h1 id="quizTitle"></h1>
<p class="description" id="quizDescription"></p>
<div class="results" id="results">
<h2>🎉 Quiz abgeschlossen!</h2>
<div class="score" id="scoreDisplay"></div>
<div class="percentage" id="percentageDisplay"></div>
</div>
<div id="questionsContainer"></div>
<div class="btn-group">
<button class="btn btn-primary" id="checkBtn" onclick="checkAnswers()" disabled>
Antworten überprüfen
</button>
<button class="btn btn-primary" id="nextBtn" onclick="nextQuestion()" style="display: none;">
Nächste Frage →
</button>
<button class="btn btn-primary" id="finishBtn" onclick="finishQuiz()" style="display: none;">
Quiz beenden
</button>
</div>
</div>
<script>
let quizData = null;
let currentQuestion = 0;
let score = 0;
let answers = [];
function loadQuiz() {
const urlParams = new URLSearchParams(window.location.search);
const dataParam = urlParams.get('data');
if (dataParam) {
try {
quizData = JSON.parse(decodeURIComponent(dataParam));
} catch (e) {
alert('Fehler beim Laden des Quiz!');
return;
}
}
if (!quizData) {
alert('Keine Quiz-Daten gefunden!');
return;
}
document.getElementById('quizTitle').textContent = quizData.title;
document.getElementById('quizDescription').textContent = quizData.description || '';
answers = new Array(quizData.questions.length).fill(null).map(() => []);
showQuestion(0);
}
function showQuestion(index) {
currentQuestion = index;
const question = quizData.questions[index];
const container = document.getElementById('questionsContainer');
// Update progress
const progress = ((index + 1) / quizData.questions.length) * 100;
document.getElementById('progressFill').style.width = progress + '%';
container.innerHTML = `
<div class="question-card">
<div class="question-number">Frage ${index + 1} von ${quizData.questions.length}</div>
<div class="question-text">${question.text}</div>
${question.answers.filter(a => a.text).map((answer, aIndex) => `
<div class="answer" id="answer_${aIndex}" onclick="selectAnswer(${aIndex})">
<div class="checkbox"></div>
<div class="answer-text">${answer.text}</div>
</div>
`).join('')}
<div class="feedback" id="feedback"></div>
</div>
`;
// Reset buttons
document.getElementById('checkBtn').style.display = 'inline-block';
document.getElementById('checkBtn').disabled = true;
document.getElementById('nextBtn').style.display = 'none';
document.getElementById('finishBtn').style.display = 'none';
}
function selectAnswer(answerIndex) {
const question = quizData.questions[currentQuestion];
// Check if multiple answers are allowed
const correctCount = question.answers.filter(a => a.correct).length;
const isMultipleChoice = correctCount > 1;
if (!isMultipleChoice) {
// Single choice - deselect all others
answers[currentQuestion] = [answerIndex];
document.querySelectorAll('.answer').forEach((el, idx) => {
el.classList.toggle('selected', idx === answerIndex);
});
} else {
// Multiple choice - toggle
const answerEl = document.getElementById('answer_' + answerIndex);
const isSelected = answerEl.classList.contains('selected');
if (isSelected) {
answerEl.classList.remove('selected');
answers[currentQuestion] = answers[currentQuestion].filter(a => a !== answerIndex);
} else {
answerEl.classList.add('selected');
answers[currentQuestion].push(answerIndex);
}
}
// Enable check button if at least one answer is selected
document.getElementById('checkBtn').disabled = answers[currentQuestion].length === 0;
}
function checkAnswers() {
const question = quizData.questions[currentQuestion];
const userAnswers = answers[currentQuestion];
const feedback = document.getElementById('feedback');
let isCorrect = true;
// Check each answer
question.answers.forEach((answer, index) => {
if (!answer.text) return; // Skip empty answers
const answerEl = document.getElementById('answer_' + index);
const isSelected = userAnswers.includes(index);
const shouldBeCorrect = answer.correct;
if (shouldBeCorrect) {
answerEl.classList.add('correct');
if (!isSelected) {
isCorrect = false;
}
} else if (isSelected) {
answerEl.classList.add('incorrect');
isCorrect = false;
}
// Disable clicking
answerEl.style.cursor = 'default';
answerEl.onclick = null;
});
// Show feedback
if (isCorrect) {
feedback.className = 'feedback correct';
feedback.textContent = '✅ Richtig! Sehr gut!';
score++;
} else {
feedback.className = 'feedback incorrect';
feedback.textContent = '❌ Leider falsch. Die richtigen Antworten sind grün markiert.';
}
// Show appropriate button
document.getElementById('checkBtn').style.display = 'none';
if (currentQuestion < quizData.questions.length - 1) {
document.getElementById('nextBtn').style.display = 'inline-block';
} else {
document.getElementById('finishBtn').style.display = 'inline-block';
}
}
function nextQuestion() {
if (currentQuestion < quizData.questions.length - 1) {
showQuestion(currentQuestion + 1);
}
}
function finishQuiz() {
const percentage = Math.round((score / quizData.questions.length) * 100);
document.getElementById('scoreDisplay').textContent = `${score} / ${quizData.questions.length}`;
document.getElementById('percentageDisplay').textContent = `${percentage}% richtig`;
document.getElementById('results').style.display = 'block';
document.getElementById('questionsContainer').style.display = 'none';
document.querySelector('.btn-group').style.display = 'none';
}
// Load quiz on page load
loadQuiz();
</script>
</body>
</html>

View File

@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timeline - BreakPilot H5P</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
min-height: 100vh;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 1000px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 12px;
font-size: 32px;
}
.description {
color: #6b7280;
margin-bottom: 40px;
font-size: 16px;
line-height: 1.6;
}
.timeline {
position: relative;
padding: 20px 0;
}
.timeline::before {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 100%;
background: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
border-radius: 2px;
}
.event {
position: relative;
margin-bottom: 60px;
display: flex;
align-items: center;
opacity: 0;
animation: fadeIn 0.6s forwards;
}
.event:nth-child(odd) {
flex-direction: row-reverse;
}
@keyframes fadeIn {
to { opacity: 1; }
}
.event-content {
width: calc(50% - 40px);
background: #f9fafb;
border: 2px solid #e5e7eb;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
transition: all 0.3s;
}
.event-content:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0,0,0,0.12);
border-color: #667eea;
}
.event:nth-child(odd) .event-content {
margin-left: 40px;
}
.event:nth-child(even) .event-content {
margin-right: 40px;
}
.event-marker {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 24px;
height: 24px;
background: white;
border: 4px solid #667eea;
border-radius: 50%;
z-index: 10;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}
.event-year {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: -30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 6px 16px;
border-radius: 20px;
font-weight: 700;
font-size: 14px;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
white-space: nowrap;
}
.event-title {
color: #1f2937;
font-size: 20px;
font-weight: 700;
margin-bottom: 8px;
}
.event-description {
color: #6b7280;
line-height: 1.6;
font-size: 15px;
}
.event-icon {
font-size: 32px;
margin-bottom: 12px;
display: block;
}
@media (max-width: 768px) {
.timeline::before {
left: 20px;
}
.event {
flex-direction: row !important;
}
.event-content {
width: calc(100% - 60px);
margin-left: 60px !important;
margin-right: 0 !important;
}
.event-marker {
left: 20px;
}
.event-year {
left: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<h1 id="title"></h1>
<p class="description" id="description"></p>
<div class="timeline" id="timeline"></div>
</div>
<script>
let data = null;
const icons = ['📍', '🎯', '⭐', '🏆', '💡', '🚀', '🎓', '🏛️', '🎨', '🌟'];
function loadContent() {
const urlParams = new URLSearchParams(window.location.search);
const dataParam = urlParams.get('data');
if (dataParam) {
try {
data = JSON.parse(decodeURIComponent(dataParam));
} catch (e) {
alert('Fehler beim Laden!');
return;
}
}
if (!data) {
alert('Keine Daten gefunden!');
return;
}
document.getElementById('title').textContent = data.title;
document.getElementById('description').textContent = data.description || '';
renderTimeline();
}
function renderTimeline() {
const timeline = document.getElementById('timeline');
timeline.innerHTML = '';
data.events.forEach((event, index) => {
const eventEl = document.createElement('div');
eventEl.className = 'event';
eventEl.style.animationDelay = `${index * 0.1}s`;
const icon = icons[index % icons.length];
eventEl.innerHTML = `
<div class="event-content">
<span class="event-icon">${icon}</span>
<div class="event-title">${event.title}</div>
<div class="event-description">${event.description || ''}</div>
</div>
<div class="event-marker"></div>
<div class="event-year">${event.year}</div>
`;
timeline.appendChild(eventEl);
});
}
loadContent();
</script>
</body>
</html>

View File

@@ -0,0 +1,377 @@
/**
* BreakPilot H5P Service - Simplified Version
* Minimal H5P integration without h5p-express complexity
*/
import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 8080;
// Middleware
app.use(cors());
app.use(express.json({ limit: '500mb' }));
app.use(express.urlencoded({ extended: true, limit: '500mb' }));
// Serve static H5P files
app.use('/h5p/core', express.static(path.join(__dirname, 'h5p-core')));
app.use('/h5p/libraries', express.static(path.join(__dirname, 'h5p-libraries')));
app.use('/h5p/content', express.static(path.join(__dirname, 'h5p-content')));
// Serve editors
app.use('/h5p/editors', express.static(path.join(__dirname, 'editors')));
// Serve players
app.use('/h5p/players', express.static(path.join(__dirname, 'players')));
// Content type specific editors
app.get('/h5p/editor/quiz', (req, res) => {
res.sendFile(path.join(__dirname, 'editors', 'quiz-editor.html'));
});
app.get('/h5p/player/quiz', (req, res) => {
res.sendFile(path.join(__dirname, 'players', 'quiz-player.html'));
});
app.get('/h5p/editor/flashcards', (req, res) => {
res.sendFile(path.join(__dirname, 'editors', 'flashcards-editor.html'));
});
app.get('/h5p/editor/fill-blanks', (req, res) => {
res.sendFile(path.join(__dirname, 'editors', 'fill-blanks-editor.html'));
});
app.get('/h5p/player/fill-blanks', (req, res) => {
res.sendFile(path.join(__dirname, 'players', 'fill-blanks-player.html'));
});
app.get('/h5p/editor/memory', (req, res) => {
res.sendFile(path.join(__dirname, 'editors', 'memory-editor.html'));
});
app.get('/h5p/player/memory', (req, res) => {
res.sendFile(path.join(__dirname, 'players', 'memory-player.html'));
});
app.get('/h5p/editor/drag-drop', (req, res) => {
res.sendFile(path.join(__dirname, 'editors', 'drag-drop-editor.html'));
});
app.get('/h5p/player/drag-drop', (req, res) => {
res.sendFile(path.join(__dirname, 'players', 'drag-drop-player.html'));
});
app.get('/h5p/editor/timeline', (req, res) => {
res.sendFile(path.join(__dirname, 'editors', 'timeline-editor.html'));
});
app.get('/h5p/player/timeline', (req, res) => {
res.sendFile(path.join(__dirname, 'players', 'timeline-player.html'));
});
app.get('/h5p/editor/interactive-video', (req, res) => {
res.sendFile(path.join(__dirname, 'editors', 'interactive-video-editor.html'));
});
app.get('/h5p/player/interactive-video', (req, res) => {
res.sendFile(path.join(__dirname, 'players', 'interactive-video-player.html'));
});
app.get('/h5p/editor/course-presentation', (req, res) => {
res.sendFile(path.join(__dirname, 'editors', 'course-presentation-editor.html'));
});
app.get('/h5p/player/course-presentation', (req, res) => {
res.sendFile(path.join(__dirname, 'players', 'course-presentation-player.html'));
});
// Main H5P Editor Selection Page
app.get('/h5p/editor/new', (req, res) => {
res.send(`<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>H5P Editor - BreakPilot</title>
<!-- H5P Core Styles -->
<link rel="stylesheet" href="/h5p/core/styles/h5p.css">
<link rel="stylesheet" href="/h5p/core/styles/h5p-confirmation-dialog.css">
<link rel="stylesheet" href="/h5p/core/styles/h5p-core-button.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
max-width: 1400px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 20px;
font-size: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.status {
display: inline-block;
background: #10b981;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.info-box {
background: #f0f9ff;
border-left: 4px solid #3b82f6;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
.info-box h3 {
color: #1e40af;
margin-bottom: 8px;
font-size: 16px;
}
.info-box p {
color: #475569;
line-height: 1.6;
margin: 0;
}
.content-types {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
margin-top: 24px;
}
.content-type-card {
background: #fff;
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
cursor: pointer;
transition: all 0.2s;
}
.content-type-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
transform: translateY(-2px);
}
.content-type-card h4 {
color: #1e293b;
margin-bottom: 8px;
font-size: 16px;
}
.content-type-card p {
color: #64748b;
font-size: 14px;
line-height: 1.5;
}
.icon {
font-size: 32px;
margin-bottom: 12px;
display: block;
}
</style>
</head>
<body>
<div class="container">
<h1>
🎓 H5P Content Creator
<span class="status">Beta</span>
</h1>
<div class="info-box">
<h3>📚 Willkommen im H5P Content Creator!</h3>
<p>
Erstelle interaktive Lerninhalte wie Quizze, Videos mit Fragen, Präsentationen und vieles mehr.
H5P (HTML5 Package) ermöglicht es, ansprechende und interaktive Bildungsinhalte zu erstellen,
die auf allen Geräten funktionieren.
</p>
</div>
<h2 style="margin: 32px 0 16px 0; color: #334155; font-size: 20px;">Beliebte Content-Typen</h2>
<div class="content-types">
<div class="content-type-card" onclick="window.location.href='/h5p/editor/quiz'">
<span class="icon">✍️</span>
<h4>Quiz (Question Set)</h4>
<p>Multiple-Choice-Tests mit sofortigem Feedback und Punktebewertung</p>
</div>
<div class="content-type-card" onclick="window.location.href='/h5p/editor/interactive-video'">
<span class="icon">🎬</span>
<h4>Interactive Video</h4>
<p>Videos mit eingebetteten Fragen, Links und anderen interaktiven Elementen</p>
</div>
<div class="content-type-card" onclick="window.location.href='/h5p/editor/course-presentation'">
<span class="icon">📊</span>
<h4>Course Presentation</h4>
<p>Präsentationen mit interaktiven Folien, Fragen und Multimedia-Inhalten</p>
</div>
<div class="content-type-card" onclick="window.location.href='/h5p/editor/flashcards'">
<span class="icon">🃏</span>
<h4>Flashcards</h4>
<p>Lernkarten zum Üben und Wiederholen von Vokabeln und Konzepten</p>
</div>
<div class="content-type-card" onclick="window.location.href='/h5p/editor/timeline'">
<span class="icon">📅</span>
<h4>Timeline</h4>
<p>Interaktive Zeitstrahle mit Bildern, Videos und Beschreibungen</p>
</div>
<div class="content-type-card" onclick="window.location.href='/h5p/editor/drag-drop'">
<span class="icon">🎯</span>
<h4>Drag and Drop</h4>
<p>Elemente ziehen und an der richtigen Stelle ablegen - ideal für Zuordnungsaufgaben</p>
</div>
<div class="content-type-card" onclick="window.location.href='/h5p/editor/fill-blanks'">
<span class="icon">📝</span>
<h4>Fill in the Blanks</h4>
<p>Lückentexte mit automatischer Korrektur und Hinweisen</p>
</div>
<div class="content-type-card" onclick="window.location.href='/h5p/editor/memory'">
<span class="icon">🧠</span>
<h4>Memory Game</h4>
<p>Klassisches Memory-Spiel mit Bildern oder Text-Paaren</p>
</div>
</div>
<div class="info-box" style="margin-top: 32px; background: #d1fae5; border-color: #10b981;">
<h3 style="color: #065f46;">✅ Alle Editoren verfügbar!</h3>
<p style="color: #047857;">
Alle 8 Content-Typen sind jetzt verfügbar: <strong>Quiz</strong>, <strong>Interactive Video</strong>, <strong>Course Presentation</strong>, <strong>Flashcards</strong>, <strong>Timeline</strong>, <strong>Drag and Drop</strong>, <strong>Fill in the Blanks</strong> und <strong>Memory Game</strong>!
Klicke auf eine Kachel, um den Editor zu öffnen.
</p>
</div>
</div>
<!-- H5P Core JavaScript -->
<script src="/h5p/core/jquery.js"></script>
<script src="/h5p/core/h5p.js"></script>
<script src="/h5p/core/h5p-event-dispatcher.js"></script>
<script src="/h5p/core/h5p-x-api-event.js"></script>
<script src="/h5p/core/h5p-x-api.js"></script>
<script src="/h5p/core/h5p-content-type.js"></script>
<script src="/h5p/core/h5p-confirmation-dialog.js"></script>
<script src="/h5p/core/h5p-action-bar.js"></script>
<script>
console.log('H5P Editor page loaded');
console.log('H5P Core version:', typeof H5P !== 'undefined' ? 'loaded' : 'not loaded');
</script>
</body>
</html>`);
});
// Health & Info
app.get('/', (req, res) => {
res.send(`
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BreakPilot H5P Service</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
max-width: 600px;
width: 100%;
}
h1 { color: #333; margin-bottom: 10px; font-size: 32px; }
.status {
display: inline-block;
background: #10b981;
color: white;
padding: 6px 16px;
border-radius: 20px;
font-size: 14px;
margin: 20px 0;
}
.btn {
display: block;
padding: 12px 24px;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 8px;
text-align: center;
font-weight: 600;
margin: 10px 0;
}
.btn:hover { background: #5568d3; }
</style>
</head>
<body>
<div class="container">
<h1>🎓 H5P Service</h1>
<div class="status">✅ Running (Simplified)</div>
<p style="color: #64748b; margin: 20px 0;">
Vereinfachte H5P-Integration für BreakPilot Studio
</p>
<a href="/h5p/editor/new" class="btn">H5P Editor öffnen</a>
<a href="/health" class="btn" style="background: #e2e8f0; color: #4a5568;">Health Check</a>
</div>
</body>
</html>
`);
});
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
service: 'h5p-service-simplified',
version: '1.0.0-simple'
});
});
// Export app for testing
export { app };
// Start server only when run directly (not imported for tests)
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
app.listen(PORT, () => {
console.log(`
╔════════════════════════════════════════════════════╗
║ 🎓 BreakPilot H5P Service (Simplified) ║
║ 📍 http://localhost:${PORT}
║ ✅ Ready to serve H5P content! ║
╚════════════════════════════════════════════════════╝
`);
});
}

438
h5p-service/server.js Normal file
View File

@@ -0,0 +1,438 @@
/**
* BreakPilot H5P Service
* Self-hosted H5P Interactive Content Server using @lumieducation/h5p-express
*/
import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import bodyParser from 'body-parser';
import {
H5PEditor,
H5PPlayer,
fsImplementations,
H5PConfig
} from '@lumieducation/h5p-server';
import {
h5pAjaxExpressRouter,
libraryAdministrationExpressRouter,
contentTypeCacheExpressRouter
} from '@lumieducation/h5p-express';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 8080;
// Middleware
app.use(cors());
app.use(bodyParser.json({ limit: '500mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '500mb' }));
// H5P Configuration
const config = new H5PConfig(
new fsImplementations.InMemoryStorage(),
{
baseUrl: 'http://localhost:8003',
contentFilesUrl: '/h5p/content',
downloadUrl: '/h5p/download',
coreUrl: '/h5p/core',
librariesUrl: '/h5p/libraries',
playUrl: '/h5p/play',
ajaxUrl: '/h5p/ajax'
}
);
// Storage implementations
const contentStorage = new fsImplementations.FileContentStorage(
process.env.H5P_STORAGE_PATH || path.join(__dirname, 'h5p-content')
);
const libraryStorage = new fsImplementations.FileLibraryStorage(
path.join(__dirname, 'h5p-libraries')
);
const temporaryStorage = new fsImplementations.DirectoryTemporaryFileStorage(
path.join(__dirname, 'h5p-temp')
);
// Initialize H5P Editor and Player
let h5pEditor;
let h5pPlayer;
async function initH5P() {
try {
h5pEditor = new H5PEditor(
contentStorage,
config,
libraryStorage,
undefined,
undefined,
temporaryStorage
);
h5pPlayer = new H5PPlayer(
libraryStorage,
contentStorage,
config
);
// Install H5P core files
await h5pEditor.installLibraryFromHub('H5P.Column');
console.log('✅ H5P Editor and Player initialized');
return true;
} catch (error) {
console.error('⚠️ H5P initialization:', error.message);
// Continue even if library install fails - editor will show library list
return true;
}
}
// User object for H5P (simplified)
const getUser = (req) => ({
id: req.headers['x-user-id'] || 'anonymous',
name: req.headers['x-user-name'] || 'Anonymous',
email: req.headers['x-user-email'] || 'anonymous@breakpilot.app',
canInstallRecommended: true,
canUpdateAndInstallLibraries: true,
canCreateRestricted: true,
type: 'local'
});
// Function to register H5P routes (called after init)
function registerH5PRoutes() {
// Serve H5P core static files (JS/CSS)
app.use('/h5p/core', express.static(path.join(__dirname, 'h5p-core')));
app.use('/h5p/editor', express.static(path.join(__dirname, 'h5p-core')));
// Serve H5P libraries
app.use('/h5p/libraries', express.static(path.join(__dirname, 'h5p-libraries')));
// Serve H5P content files
app.use('/h5p/content', express.static(path.join(__dirname, 'h5p-content')));
// ============= H5P AJAX ROUTES (from h5p-express) =============
app.use(
'/h5p/ajax',
h5pAjaxExpressRouter(
h5pEditor,
path.resolve('h5p-core'),
path.resolve('h5p-libraries'),
(req) => getUser(req)
)
);
// ============= LIBRARY ADMINISTRATION =============
app.use(
'/h5p/libraries-admin',
libraryAdministrationExpressRouter(
h5pEditor,
(req) => getUser(req)
)
);
// ============= CONTENT TYPE CACHE =============
app.use(
'/h5p/content-type-cache',
contentTypeCacheExpressRouter(
h5pEditor,
(req) => getUser(req)
)
);
}
// ============= EDITOR & PLAYER HTML PAGES =============
// Create new H5P content (Editor UI)
app.get('/h5p/editor/new', async (req, res) => {
try {
const editorModel = await h5pEditor.render(undefined, 'en', getUser(req));
const html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>H5P Editor - BreakPilot</title>
${editorModel.styles.map(style => `<link rel="stylesheet" href="${style}"/>`).join('\n ')}
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 20px;
}
.h5p-editor-container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 20px;
font-size: 24px;
}
#h5p-editor-container {
min-height: 500px;
}
</style>
</head>
<body>
<div class="h5p-editor-container">
<h1>🎓 H5P Content Creator</h1>
${editorModel.html}
</div>
<script>
window.H5PIntegration = ${JSON.stringify(editorModel.integration, null, 2)};
</script>
${editorModel.scripts.map(script => `<script src="${script}"></script>`).join('\n ')}
</body>
</html>`;
res.send(html);
} catch (error) {
console.error('Editor render error:', error);
res.status(500).send(`
<h1>Error loading H5P Editor</h1>
<p>Error: ${error.message}</p>
<p>Please check that H5P libraries are installed.</p>
`);
}
});
// Edit existing H5P content
app.get('/h5p/editor/:contentId', async (req, res) => {
try {
const editorModel = await h5pEditor.render(req.params.contentId, 'en', getUser(req));
const html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>H5P Editor - ${req.params.contentId}</title>
${editorModel.styles.map(style => `<link rel="stylesheet" href="${style}"/>`).join('\n ')}
</head>
<body>
<h1>Edit H5P Content</h1>
${editorModel.html}
<script>window.H5PIntegration = ${JSON.stringify(editorModel.integration)};</script>
${editorModel.scripts.map(script => `<script src="${script}"></script>`).join('\n ')}
</body>
</html>`;
res.send(html);
} catch (error) {
res.status(500).send(`Error: ${error.message}`);
}
});
// Play H5P content
app.get('/h5p/play/:contentId', async (req, res) => {
try {
const playerModel = await h5pPlayer.render(req.params.contentId, getUser(req));
res.send(`<!DOCTYPE html>
<html>
<head>
<title>H5P Player</title>
${playerModel.styles.map(s => `<link rel="stylesheet" href="${s}"/>`).join('\n ')}
</head>
<body>
<div class="h5p-player">
${playerModel.html}
</div>
<script>window.H5PIntegration = ${JSON.stringify(playerModel.integration)};</script>
${playerModel.scripts.map(s => `<script src="${s}"></script>`).join('\n ')}
</body>
</html>`);
} catch (error) {
res.status(500).send(`Error: ${error.message}`);
}
});
// ============= CONTENT MANAGEMENT =============
// Save/Update content
app.post('/h5p/content/:contentId?', async (req, res) => {
try {
const contentId = req.params.contentId;
const { library, params } = req.body;
const savedId = await h5pEditor.saveOrUpdateContent(
contentId || undefined,
params,
library,
getUser(req)
);
res.json({
success: true,
contentId: savedId,
message: 'Content saved successfully'
});
} catch (error) {
console.error('Save content error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// List all content
app.get('/h5p/content', async (req, res) => {
try {
const contentIds = await contentStorage.listContent();
res.json({ contentIds });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Delete content
app.delete('/h5p/content/:contentId', async (req, res) => {
try {
await contentStorage.deleteContent(req.params.contentId, getUser(req));
res.json({ success: true, message: 'Content deleted' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============= INFO & HEALTH ENDPOINTS =============
app.get('/', (req, res) => {
res.send(`
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BreakPilot H5P Service</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
max-width: 600px;
width: 100%;
}
h1 { color: #333; margin-bottom: 10px; font-size: 32px; }
.status {
display: inline-block;
background: #10b981;
color: white;
padding: 6px 16px;
border-radius: 20px;
font-size: 14px;
margin: 20px 0;
}
.btn {
display: block;
padding: 12px 24px;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 8px;
text-align: center;
font-weight: 600;
margin: 10px 0;
}
.btn:hover { background: #5568d3; }
</style>
</head>
<body>
<div class="container">
<h1>🎓 H5P Service</h1>
<div class="status">✅ Running</div>
<a href="/h5p/editor/new" class="btn">Create New H5P Content</a>
<a href="/health" class="btn" style="background: #e2e8f0; color: #4a5568;">Health Check</a>
</div>
</body>
</html>
`);
});
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
service: 'h5p-service',
version: '1.0.0'
});
});
app.get('/info', (req, res) => {
res.json({
service: 'BreakPilot H5P Service',
version: '1.0.0',
endpoints: {
root: '/',
health: '/health',
editorNew: '/h5p/editor/new',
editorEdit: '/h5p/editor/:contentId',
player: '/h5p/play/:contentId',
contentList: '/h5p/content'
}
});
});
// Debug endpoint to see what render() returns
app.get('/debug/editor-model', async (req, res) => {
try {
const model = await h5pEditor.render(undefined, 'en', getUser(req));
res.json({
styles: model.styles,
scripts: model.scripts,
integration: model.integration
});
} catch (error) {
res.status(500).json({ error: error.message, stack: error.stack });
}
});
// Start server
async function start() {
try {
await initH5P();
// Register H5P routes after initialization
registerH5PRoutes();
app.listen(PORT, () => {
console.log(`
╔════════════════════════════════════════════════════╗
║ 🎓 BreakPilot H5P Service ║
║ 📍 http://localhost:${PORT}
║ ✅ Ready to create interactive content! ║
╚════════════════════════════════════════════════════╝
`);
});
} catch (error) {
console.error('Failed to start H5P service:', error);
process.exit(1);
}
}
start();

213
h5p-service/setup-h5p.js Normal file
View File

@@ -0,0 +1,213 @@
/**
* H5P Core Files Setup Script
*
* This script downloads and sets up the H5P core files required for the editor and player.
* It creates the necessary directory structure and downloads essential libraries.
*/
import fs from 'fs';
import path from 'path';
import https from 'https';
import { fileURLToPath } from 'url';
import { pipeline } from 'stream/promises';
import { createWriteStream } from 'fs';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const H5P_CORE_VERSION = '1.24';
const H5P_CORE_URL = `https://github.com/h5p/h5p-php-library/archive/refs/tags/${H5P_CORE_VERSION}.zip`;
// Create required directories
const dirs = [
'h5p-core',
'h5p-libraries',
'h5p-content',
'h5p-temp'
];
console.log('🎓 Setting up H5P Service...\n');
// Create directories
console.log('📁 Creating directories...');
dirs.forEach(dir => {
const dirPath = path.join(__dirname, dir);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
console.log(` ✅ Created ${dir}`);
} else {
console.log(` ⏭️ ${dir} already exists`);
}
});
// Download H5P core files
async function downloadH5PCore() {
console.log('\n📦 Downloading H5P Core files...');
console.log(` Version: ${H5P_CORE_VERSION}`);
const zipPath = path.join(__dirname, 'h5p-core.zip');
const extractPath = path.join(__dirname, 'h5p-core-temp');
try {
// Download core zip
await new Promise((resolve, reject) => {
https.get(H5P_CORE_URL, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
https.get(response.headers.location, (redirectResponse) => {
const fileStream = createWriteStream(zipPath);
redirectResponse.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close();
console.log(' ✅ Downloaded H5P core');
resolve();
});
}).on('error', reject);
} else {
const fileStream = createWriteStream(zipPath);
response.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close();
console.log(' ✅ Downloaded H5P core');
resolve();
});
}
}).on('error', reject);
});
// Extract zip
console.log(' 📂 Extracting files...');
// Check if unzip is available
try {
await execAsync(`unzip -q "${zipPath}" -d "${extractPath}"`);
} catch (error) {
console.log(' ⚠️ unzip not available, trying alternative method...');
// Alternative: use Node.js built-in extraction if available
throw new Error('Please install unzip: brew install unzip (macOS) or apt-get install unzip (Linux)');
}
// Move core files to h5p-core directory
const extractedDir = fs.readdirSync(extractPath)[0];
const coreSrcPath = path.join(extractPath, extractedDir, 'js');
const coreDestPath = path.join(__dirname, 'h5p-core');
if (fs.existsSync(coreSrcPath)) {
// Copy js files
await execAsync(`cp -r "${coreSrcPath}"/* "${coreDestPath}/"`);
console.log(' ✅ H5P core files installed');
}
// Copy styles
const stylesSrcPath = path.join(extractPath, extractedDir, 'styles');
if (fs.existsSync(stylesSrcPath)) {
const stylesDestPath = path.join(__dirname, 'h5p-core', 'styles');
fs.mkdirSync(stylesDestPath, { recursive: true });
await execAsync(`cp -r "${stylesSrcPath}"/* "${stylesDestPath}/"`);
console.log(' ✅ H5P styles installed');
}
// Cleanup
fs.unlinkSync(zipPath);
fs.rmSync(extractPath, { recursive: true, force: true });
} catch (error) {
console.error(' ❌ Error downloading H5P core:', error.message);
console.log('\n 💡 Manual setup:');
console.log(' 1. Download from: https://github.com/h5p/h5p-php-library/releases');
console.log(' 2. Extract to h5p-core/ directory');
}
}
// Create basic H5P integration files
function createIntegrationFiles() {
console.log('\n📝 Creating integration files...');
// Create a minimal H5PIntegration.js file
const integrationPath = path.join(__dirname, 'h5p-core', 'H5PIntegration.js');
if (!fs.existsSync(integrationPath)) {
const integrationContent = `
// H5P Integration
// This file is automatically generated by setup-h5p.js
var H5PIntegration = H5PIntegration || {};
console.log('H5P Core loaded');
`.trim();
fs.writeFileSync(integrationPath, integrationContent);
console.log(' ✅ Created H5PIntegration.js');
} else {
console.log(' ⏭️ H5PIntegration.js already exists');
}
}
// Create README
function createReadme() {
const readme = `# H5P Service
## Setup erfolgreich!
Die folgenden Verzeichnisse wurden erstellt:
- \`h5p-core/\` - H5P Core JavaScript und CSS
- \`h5p-libraries/\` - Installierte H5P Content Type Libraries
- \`h5p-content/\` - Erstellte H5P Contents
- \`h5p-temp/\` - Temporäre Dateien
## H5P Content Types installieren
Um Content Types (wie Interactive Video, Quiz, etc.) zu verwenden, müssen diese
über die H5P Hub API installiert werden.
Der Editor zeigt verfügbare Content Types automatisch an.
## Entwicklung
\`\`\`bash
npm run dev # Start mit nodemon (auto-reload)
npm start # Production start
\`\`\`
## Endpoints
- \`GET /h5p/editor/new\` - Neuen Content erstellen
- \`GET /h5p/editor/:id\` - Content bearbeiten
- \`POST /h5p/editor/:id\` - Content speichern
- \`GET /h5p/play/:id\` - Content abspielen
- \`GET /h5p/libraries\` - Installierte Libraries
## Dokumentation
- H5P Official: https://h5p.org
- @lumieducation/h5p-server: https://github.com/Lumieducation/H5P-Nodejs-library
`;
fs.writeFileSync(path.join(__dirname, 'H5P-README.md'), readme);
console.log(' ✅ Created H5P-README.md');
}
// Main setup function
async function setup() {
try {
await downloadH5PCore();
createIntegrationFiles();
createReadme();
console.log('\n✅ H5P Service setup complete!\n');
console.log('📚 Next steps:');
console.log(' 1. Start the service: npm start');
console.log(' 2. Open http://localhost:8080');
console.log(' 3. Create H5P content via /h5p/editor/new');
console.log('');
} catch (error) {
console.error('\n❌ Setup failed:', error);
process.exit(1);
}
}
setup();

170
h5p-service/tests/README.md Normal file
View File

@@ -0,0 +1,170 @@
# H5P Service Tests
## Overview
Dieser Ordner enthält Integration Tests für den BreakPilot H5P Service.
## Test-Struktur
```
tests/
├── README.md # Diese Datei
├── setup.js # Jest Test Setup
└── server.test.js # Integration Tests für Server Endpoints
```
## Test-Coverage
Die Tests decken folgende Bereiche ab:
### 1. Health & Info Endpoints
- `GET /` - Service Info Page
- `GET /health` - Health Check
### 2. Editor Selection Page
- `GET /h5p/editor/new` - Hauptseite mit allen 8 Content-Typen
### 3. Content Type Editors (8 Typen)
- Quiz Editor
- Interactive Video Editor
- Course Presentation Editor
- Flashcards Editor
- Timeline Editor
- Drag and Drop Editor
- Fill in the Blanks Editor
- Memory Game Editor
### 4. Content Type Players (8 Typen)
- Quiz Player
- Interactive Video Player
- Course Presentation Player
- Flashcards Player (coming soon)
- Timeline Player
- Drag and Drop Player
- Fill in the Blanks Player
- Memory Game Player
### 5. Static File Serving
- `/h5p/core/*` - H5P Core Files
- `/h5p/editors/*` - Editor HTML Files
- `/h5p/players/*` - Player HTML Files
### 6. Error Handling
- 404 für nicht existierende Routes
- Invalid Editor/Player Routes
## Tests ausführen
### Lokale Entwicklung
```bash
# Alle Tests ausführen
npm test
# Tests mit Watch-Mode
npm run test:watch
# Tests für CI/CD
npm run test:ci
```
### Docker Container Tests
```bash
# Service starten
docker compose -f docker-compose.content.yml up -d h5p-service
# Tests im Container ausführen
docker compose -f docker-compose.content.yml exec h5p-service npm test
```
## Test-Konfiguration
### Environment Variables
| Variable | Default | Beschreibung |
|----------|---------|--------------|
| `H5P_TEST_URL` | `http://localhost:8080` | Base URL für Tests |
### Jest Konfiguration
Siehe `jest.config.js` für Details:
- Test Timeout: 10000ms
- Coverage Reports: text, lcov, html
- Test Match: `**/tests/**/*.test.js`
## Coverage
Coverage Reports werden generiert in:
- `coverage/lcov-report/index.html` (HTML Report)
- `coverage/lcov.info` (LCOV Format)
- Terminal Output
Ziel: >80% Coverage
## Neue Tests hinzufügen
### Test-Template
```javascript
describe('Feature Name', () => {
test('should do something', async () => {
const response = await request(BASE_URL).get('/endpoint');
expect(response.status).toBe(200);
expect(response.text).toContain('Expected Content');
});
});
```
## Troubleshooting
### Service nicht erreichbar
```bash
# Service Status prüfen
docker compose -f docker-compose.content.yml ps
# Logs ansehen
docker compose -f docker-compose.content.yml logs h5p-service
# Service neu starten
docker compose -f docker-compose.content.yml restart h5p-service
```
### Tests schlagen fehl
1. Prüfe, ob Service läuft: `curl http://localhost:8003/health`
2. Prüfe Logs: `docker compose -f docker-compose.content.yml logs h5p-service`
3. Rebuilde Container: `docker compose -f docker-compose.content.yml up -d --build h5p-service`
## Best Practices
1. **Isolierte Tests**: Jeder Test sollte unabhängig laufen
2. **Cleanup**: Tests sollten keine persistenten Änderungen hinterlassen
3. **Assertions**: Klare und aussagekräftige Expectations
4. **Beschreibungen**: Aussagekräftige test/describe Namen
5. **Speed**: Integration Tests sollten <10s dauern
## CI/CD Integration
Die Tests werden automatisch ausgeführt bei:
- Pull Requests
- Commits auf `main` branch
- Release Builds
GitHub Actions Workflow:
```yaml
- name: Run H5P Service Tests
run: |
docker compose -f docker-compose.content.yml up -d h5p-service
docker compose -f docker-compose.content.yml exec h5p-service npm run test:ci
```
## Zukünftige Erweiterungen
- [ ] E2E Tests mit Playwright
- [ ] Performance Tests
- [ ] Content Validation Tests
- [ ] Security Tests (XSS, CSRF)
- [ ] Load Tests

View File

@@ -0,0 +1,236 @@
/**
* H5P Service Integration Tests (ESM)
* Tests für alle H5P Service Endpoints
*/
import request from 'supertest';
import { describe, test, expect } from '@jest/globals';
import { app } from '../server-simple.js';
describe('H5P Service - Health & Info', () => {
test('GET / should return service info page', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
expect(response.text).toContain('H5P Service');
expect(response.text).toContain('Running');
});
test('GET /health should return healthy status', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
status: 'healthy',
service: 'h5p-service-simplified'
});
});
});
describe('H5P Service - Editor Selection Page', () => {
test('GET /h5p/editor/new should return editor selection page', async () => {
const response = await request(app).get('/h5p/editor/new');
expect(response.status).toBe(200);
expect(response.text).toContain('H5P Content Creator');
expect(response.text).toContain('Quiz');
expect(response.text).toContain('Interactive Video');
expect(response.text).toContain('Course Presentation');
expect(response.text).toContain('Flashcards');
expect(response.text).toContain('Timeline');
expect(response.text).toContain('Drag and Drop');
expect(response.text).toContain('Fill in the Blanks');
expect(response.text).toContain('Memory Game');
});
test('Editor page should contain all 8 content types', async () => {
const response = await request(app).get('/h5p/editor/new');
const contentTypes = [
'Quiz',
'Interactive Video',
'Course Presentation',
'Flashcards',
'Timeline',
'Drag and Drop',
'Fill in the Blanks',
'Memory Game'
];
contentTypes.forEach(type => {
expect(response.text).toContain(type);
});
});
});
describe('H5P Service - Quiz Editor & Player', () => {
test('GET /h5p/editor/quiz should return quiz editor', async () => {
const response = await request(app).get('/h5p/editor/quiz');
expect(response.status).toBe(200);
expect(response.text).toContain('Quiz Editor');
expect(response.text).toContain('Frage hinzufügen');
});
test('GET /h5p/player/quiz should return quiz player', async () => {
const response = await request(app).get('/h5p/player/quiz');
expect(response.status).toBe(200);
expect(response.text).toContain('Quiz');
});
});
describe('H5P Service - Interactive Video Editor & Player', () => {
test('GET /h5p/editor/interactive-video should return video editor', async () => {
const response = await request(app).get('/h5p/editor/interactive-video');
expect(response.status).toBe(200);
expect(response.text).toContain('Interactive Video Editor');
expect(response.text).toContain('Video-URL');
});
test('GET /h5p/player/interactive-video should return video player', async () => {
const response = await request(app).get('/h5p/player/interactive-video');
expect(response.status).toBe(200);
expect(response.text).toContain('Interactive Video');
});
});
describe('H5P Service - Course Presentation Editor & Player', () => {
test('GET /h5p/editor/course-presentation should return presentation editor', async () => {
const response = await request(app).get('/h5p/editor/course-presentation');
expect(response.status).toBe(200);
expect(response.text).toContain('Course Presentation Editor');
expect(response.text).toContain('Folien');
});
test('GET /h5p/player/course-presentation should return presentation player', async () => {
const response = await request(app).get('/h5p/player/course-presentation');
expect(response.status).toBe(200);
expect(response.text).toContain('Course Presentation');
});
});
describe('H5P Service - Flashcards Editor', () => {
test('GET /h5p/editor/flashcards should return flashcards editor', async () => {
const response = await request(app).get('/h5p/editor/flashcards');
expect(response.status).toBe(200);
expect(response.text).toContain('Flashcards Editor');
expect(response.text).toContain('Karte hinzufügen');
});
});
describe('H5P Service - Timeline Editor & Player', () => {
test('GET /h5p/editor/timeline should return timeline editor', async () => {
const response = await request(app).get('/h5p/editor/timeline');
expect(response.status).toBe(200);
expect(response.text).toContain('Timeline Editor');
expect(response.text).toContain('Ereignis');
});
test('GET /h5p/player/timeline should return timeline player', async () => {
const response = await request(app).get('/h5p/player/timeline');
expect(response.status).toBe(200);
expect(response.text).toContain('Timeline');
});
});
describe('H5P Service - Drag and Drop Editor & Player', () => {
test('GET /h5p/editor/drag-drop should return drag drop editor', async () => {
const response = await request(app).get('/h5p/editor/drag-drop');
expect(response.status).toBe(200);
expect(response.text).toContain('Drag and Drop Editor');
expect(response.text).toContain('Drop Zones');
});
test('GET /h5p/player/drag-drop should return drag drop player', async () => {
const response = await request(app).get('/h5p/player/drag-drop');
expect(response.status).toBe(200);
expect(response.text).toContain('Drag and Drop');
});
});
describe('H5P Service - Fill in the Blanks Editor & Player', () => {
test('GET /h5p/editor/fill-blanks should return fill blanks editor', async () => {
const response = await request(app).get('/h5p/editor/fill-blanks');
expect(response.status).toBe(200);
expect(response.text).toContain('Fill in the Blanks Editor');
expect(response.text).toContain('Lückentext');
});
test('GET /h5p/player/fill-blanks should return fill blanks player', async () => {
const response = await request(app).get('/h5p/player/fill-blanks');
expect(response.status).toBe(200);
expect(response.text).toContain('Fill in the Blanks');
});
});
describe('H5P Service - Memory Game Editor & Player', () => {
test('GET /h5p/editor/memory should return memory editor', async () => {
const response = await request(app).get('/h5p/editor/memory');
expect(response.status).toBe(200);
expect(response.text).toContain('Memory Game Editor');
expect(response.text).toContain('Paar');
});
test('GET /h5p/player/memory should return memory player', async () => {
const response = await request(app).get('/h5p/player/memory');
expect(response.status).toBe(200);
expect(response.text).toContain('Memory');
});
});
describe('H5P Service - Static Files', () => {
test('Static core files should be accessible', async () => {
const response = await request(app).get('/h5p/core/h5p.css');
// May or may not exist, but should not 500
expect([200, 404]).toContain(response.status);
});
test('Editors directory should be accessible', async () => {
const response = await request(app).get('/h5p/editors/quiz-editor.html');
expect(response.status).toBe(200);
expect(response.text).toContain('Quiz Editor');
});
test('Players directory should be accessible', async () => {
const response = await request(app).get('/h5p/players/quiz-player.html');
expect(response.status).toBe(200);
expect(response.text).toContain('Quiz');
});
});
describe('H5P Service - Error Handling', () => {
test('GET /nonexistent should return 404', async () => {
const response = await request(app).get('/nonexistent');
expect(response.status).toBe(404);
});
test('Invalid editor route should return 404', async () => {
const response = await request(app).get('/h5p/editor/nonexistent');
expect(response.status).toBe(404);
});
test('Invalid player route should return 404', async () => {
const response = await request(app).get('/h5p/player/nonexistent');
expect(response.status).toBe(404);
});
});

View File

@@ -0,0 +1,22 @@
/**
* Jest Test Setup
* Konfiguriert Testumgebung für H5P Service Tests
*
* Note: Uses global Jest functions to avoid ESM import issues
* with setupFilesAfterEnv in some Jest versions.
*/
// Timeout für alle Tests erhöhen (global.jest is available in Jest environment)
if (typeof jest !== 'undefined') {
jest.setTimeout(10000);
}
// Before all tests
beforeAll(() => {
console.log('Starting H5P Service Tests...');
});
// After all tests
afterAll(() => {
console.log('H5P Service Tests Completed');
});