33fb423967
- Neuer API-Endpoint /api/live (heartbeat / spectator) + DB-Tabelle live_sessions + class_modules.paused-Spalte - Plattform-Live-Client (assets/js/live-client.js): Heartbeat, Pause-Overlay, Spectator-Mode bei ?view=teacher - ggs_inject_live() in _scripts.php als Helper, in alle 11 Sim-Wrapper integriert - Lehrer-Cockpit: tabellarische Klassen-Live-Ansicht pro Sim, Pause-Toggle, blinkender Tab-Indikator wenn aktive Sessions - Submit-Bug erschlagen: progress.php hat jetzt submit_assessment- und reflection-Action; saves.php akzeptiert beide Key-Konventionen; alle Direkt-Pfad-API-Files mit require_once-Bootstrap - Avatar-Default: zufaelliger DALL-E-Avatar fuer neue Schueler:innen - Admin-Styleguide-Seite mit Bildstil, Farb-Tokens, Layout, iPad-Pattern - Klima 2D + 3D: GGS_LIVE_STATE-Hook, Klima 2D zusaetzlich skipResume fuer Klassenaufgaben-Reset - Test-Lehrer Jakob + 3 Schueler:innen lokal + auf Live angelegt - Rundbriefe an alle Sim-Instanzen + Submit-Briefe an sonnensystem, busfahrt, energiemanager + Glossar-Anfrage zu Grundriess - Status-Uebergabe in _inbox/zentrale/_status.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
396 lines
17 KiB
HTML
396 lines
17 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>GeoGraSim — Modul-Verwaltung</title>
|
||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96">
|
||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:'Inter',sans-serif;background:#f7f6f3;min-height:100vh}
|
||
.topbar{background:#fff;padding:.6rem 1.2rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06)}
|
||
.topbar h1{font-size:.95rem;font-weight:800;color:#4a7c8a;flex:1;display:flex;align-items:center;gap:.5rem}
|
||
.topbar h1 img{height:28px}
|
||
.btn-sm{padding:.3rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.68rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none}
|
||
.btn-sm:hover{background:#dae8ec;color:#4a7c8a}
|
||
.wrap{max-width:1200px;margin:1rem auto;padding:0 1rem}
|
||
.intro{color:#4a4a4a;font-size:.82rem;margin-bottom:1rem}
|
||
|
||
.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}
|
||
.module-card{background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.03);border:1px solid rgba(0,0,0,.04);display:flex;flex-direction:column}
|
||
.module-img{position:relative;aspect-ratio:16/10;background:#dae8ec;overflow:hidden}
|
||
.module-img img{width:100%;height:100%;object-fit:cover;display:block}
|
||
.module-img .no-img{display:flex;align-items:center;justify-content:center;height:100%;color:#8a8a8a;font-size:.72rem}
|
||
.module-img .status-badge{position:absolute;top:8px;right:8px;padding:3px 8px;border-radius:12px;font-size:.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em}
|
||
.status-aktiv{background:#dceadd;color:#3a6b3e}
|
||
.status-beta{background:#fff3cd;color:#8a6400}
|
||
.status-geplant{background:#e0e0e0;color:#666}
|
||
.status-archiv{background:#f2ddd8;color:#c07a6b}
|
||
|
||
.module-body{padding:.9rem;display:flex;flex-direction:column;gap:.6rem;flex:1}
|
||
.module-title{display:flex;align-items:center;gap:.5rem}
|
||
.module-emoji{font-size:1.8rem;line-height:1}
|
||
.module-title-text{font-size:1rem;font-weight:800;color:#1f4e5a;flex:1}
|
||
.module-sub{font-size:.74rem;color:#4a4a4a;line-height:1.35}
|
||
.module-meta{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.3rem}
|
||
.meta-chip{background:#f7f6f3;padding:2px 8px;border-radius:10px;font-size:.62rem;color:#4a4a4a}
|
||
.module-actions{display:flex;gap:.4rem;margin-top:.6rem}
|
||
.btn{padding:.45rem .9rem;border:none;border-radius:6px;background:#4a7c8a;color:#fff;font-size:.72rem;font-weight:700;cursor:pointer;font-family:inherit}
|
||
.btn:hover{background:#3a6470}
|
||
.btn-outline{background:transparent;color:#4a7c8a;border:1.5px solid #4a7c8a}
|
||
|
||
/* Edit Modal */
|
||
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:flex-start;justify-content:center;z-index:100;padding:2rem;overflow-y:auto}
|
||
.overlay.open{display:flex}
|
||
.edit-card{background:#fff;border-radius:12px;max-width:600px;width:100%;padding:1.5rem;box-shadow:0 8px 30px rgba(0,0,0,.15)}
|
||
.edit-card h2{font-size:1.1rem;font-weight:800;color:#1f4e5a;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}
|
||
.form-grid{display:grid;gap:.8rem}
|
||
.form-row{display:grid;grid-template-columns:auto 1fr;gap:.8rem;align-items:center}
|
||
.form-row label{font-size:.72rem;font-weight:600;color:#4a4a4a}
|
||
.form-row input,.form-row select,.form-row textarea{padding:.45rem .6rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.82rem;font-family:inherit;width:100%}
|
||
.form-row textarea{min-height:80px;resize:vertical}
|
||
.form-row .emoji-preview{display:inline-flex;align-items:center;gap:.4rem;background:#f7f6f3;padding:.35rem .6rem;border-radius:6px;font-size:1.4rem;min-width:2.5rem;justify-content:center}
|
||
.form-footer{display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem}
|
||
.emoji-picker{display:grid;grid-template-columns:repeat(10,1fr);gap:.3rem;margin-top:.4rem;max-height:140px;overflow-y:auto}
|
||
.emoji-pick{font-size:1.3rem;padding:.3rem;border:none;background:transparent;border-radius:6px;cursor:pointer}
|
||
.emoji-pick:hover{background:#dae8ec}
|
||
.upload-section{background:#f7f6f3;padding:.8rem;border-radius:8px}
|
||
.upload-section label{font-size:.72rem;font-weight:600;color:#4a4a4a;display:block;margin-bottom:.4rem}
|
||
.current-img{display:flex;align-items:center;gap:.6rem;margin-bottom:.6rem}
|
||
.current-img img{width:80px;height:50px;object-fit:cover;border-radius:4px}
|
||
.current-img .fname{font-family:monospace;font-size:.7rem;color:#8a8a8a}
|
||
|
||
.msg{font-size:.74rem;padding:.5rem .7rem;border-radius:6px;margin-top:.5rem}
|
||
.msg.ok{background:#dceadd;color:#3a6b3e}
|
||
.msg.err{background:#f2ddd8;color:#c07a6b}
|
||
|
||
.toolbar{display:flex;gap:.4rem;margin-bottom:1rem;flex-wrap:wrap;align-items:center}
|
||
.toolbar select{padding:.35rem .5rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.74rem;font-family:inherit}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="topbar">
|
||
<h1><img src="assets/img/textlogo_geograsim.svg" alt="GeoGraSim"> Modul-Verwaltung (Admin)</h1>
|
||
<a href="admin-licenses.html" class="btn-sm">🎟️ Lizenzen</a>
|
||
<a href="admin-levels.html" class="btn-sm">🎮 Level</a>
|
||
<a href="admin-styleguide.html" class="btn-sm">🎨 Styleguide</a>
|
||
<a href="admin.html" class="btn-sm">Logout</a>
|
||
</div>
|
||
|
||
<div class="wrap">
|
||
<p class="intro">Hier kannst du Titel, Emoji, Bild und Status aller Module anpassen. Änderungen werden sofort in die Datenbank geschrieben und sind überall in der App wirksam (Landing Page, Detailseiten, Dashboards).</p>
|
||
|
||
<div class="toolbar">
|
||
<label style="font-size:.72rem;font-weight:600">Filter:</label>
|
||
<select id="filter-status" onchange="render()">
|
||
<option value="">Alle Status</option>
|
||
<option value="aktiv">Aktiv</option>
|
||
<option value="beta">Beta</option>
|
||
<option value="geplant">Geplant</option>
|
||
<option value="archiv">Archiv</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="grid" class="module-grid">
|
||
<p style="color:#8a8a8a;font-size:.8rem">Lade Module …</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Edit Modal -->
|
||
<div class="overlay" id="overlay" onclick="if(event.target===this)closeEdit()">
|
||
<div class="edit-card">
|
||
<h2><span id="edit-emoji-display">🧩</span> <span id="edit-title-display">Modul bearbeiten</span></h2>
|
||
|
||
<div class="form-grid">
|
||
<div class="form-row">
|
||
<label>Modul-ID</label>
|
||
<input id="f-id" readonly style="background:#f7f6f3;color:#8a8a8a">
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label>Emoji</label>
|
||
<div>
|
||
<div style="display:flex;gap:.5rem;align-items:center">
|
||
<span class="emoji-preview" id="emoji-preview">🧩</span>
|
||
<input id="f-icon" maxlength="16" style="max-width:80px" oninput="document.getElementById('emoji-preview').textContent=this.value||'–';document.getElementById('edit-emoji-display').textContent=this.value||'🧩'">
|
||
<button class="btn-sm" type="button" onclick="togglePicker()">🎨 Auswählen</button>
|
||
</div>
|
||
<div id="emoji-picker" class="emoji-picker" style="display:none"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label>Titel</label>
|
||
<input id="f-title" maxlength="128" oninput="document.getElementById('edit-title-display').textContent=this.value||'Modul bearbeiten'">
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label>Untertitel</label>
|
||
<input id="f-subtitle" maxlength="255">
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label>Status</label>
|
||
<select id="f-status">
|
||
<option value="aktiv">Aktiv</option>
|
||
<option value="beta">Beta</option>
|
||
<option value="geplant">Geplant</option>
|
||
<option value="archiv">Archiv</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label>Dauer (min)</label>
|
||
<input id="f-duration" type="number" min="0">
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label>Alter</label>
|
||
<div style="display:flex;gap:.4rem;align-items:center">
|
||
<input id="f-agemin" type="number" min="5" max="20" style="width:70px">
|
||
<span>bis</span>
|
||
<input id="f-agemax" type="number" min="5" max="20" style="width:70px">
|
||
<span style="font-size:.7rem;color:#8a8a8a">Jahre</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label>Simulations-URL</label>
|
||
<input id="f-playurl" placeholder="klima-2d">
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label>Seiten-URL</label>
|
||
<input id="f-pageurl" placeholder="modul-klima">
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label>Reihenfolge</label>
|
||
<input id="f-sort" type="number">
|
||
</div>
|
||
|
||
<div class="upload-section">
|
||
<label>Card-Bild</label>
|
||
<div class="current-img">
|
||
<img id="current-img-preview" src="" alt="" style="display:none">
|
||
<span class="fname" id="current-img-name">(kein Bild)</span>
|
||
</div>
|
||
<input type="file" id="f-image" accept="image/png,image/jpeg,image/webp,image/svg+xml">
|
||
<div style="font-size:.66rem;color:#8a8a8a;margin-top:.3rem">Empfohlen: 1024×640 PNG/JPG, max 5 MB</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="edit-msg"></div>
|
||
|
||
<div class="form-footer">
|
||
<button class="btn btn-outline" onclick="closeEdit()">Abbrechen</button>
|
||
<button class="btn" onclick="save()">Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
var BASE = location.pathname.replace(/\/[^\/]*$/, '');
|
||
var modules = [];
|
||
var editingId = null;
|
||
|
||
// Kuratierte Emoji-Auswahl passend zu GeoGraSim-Themen
|
||
var EMOJI_SET = ['🌍','🌎','🌏','🗺️','🧭','🏔️','⛰️','🌋','🏕️','🏞️',
|
||
'🌊','💧','🌧️','☀️','☁️','❄️','🌡️','🌪️','🌈','💨',
|
||
'🏙️','🏛️','🏘️','🏠','🏭','🚁','✈️','🚆','🚌','🚲',
|
||
'🌲','🌳','🌴','🌿','🍃','🌱','🐟','🐝','🦋','🐼',
|
||
'⚡','🔥','💡','🔋','☢️','🛢️','⛽','🌬️','♻️','🌿',
|
||
'📊','📈','📉','🎯','🏆','🎮','📚','🧪','🔬','🧬'];
|
||
|
||
function loadModules() {
|
||
fetch(BASE + '/api/admin-modules', { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.modules) {
|
||
modules = d.modules;
|
||
render();
|
||
} else {
|
||
document.getElementById('grid').innerHTML = '<p style="color:#c07a6b">Fehler: ' + (d.error || 'unbekannt') + '</p>';
|
||
}
|
||
});
|
||
}
|
||
|
||
function render() {
|
||
var filter = document.getElementById('filter-status').value;
|
||
var filtered = filter ? modules.filter(m => m.status === filter) : modules;
|
||
var grid = document.getElementById('grid');
|
||
if (!filtered.length) {
|
||
grid.innerHTML = '<p style="color:#8a8a8a;font-size:.8rem">Keine Module vorhanden.</p>';
|
||
return;
|
||
}
|
||
grid.innerHTML = filtered.map(m => {
|
||
var imgSrc = m.card_image ? 'assets/img/' + m.card_image : '';
|
||
var img = imgSrc
|
||
? '<img src="' + imgSrc + '" alt="' + esc(m.title) + '">'
|
||
: '<div class="no-img">Kein Bild</div>';
|
||
var dur = m.duration_min ? m.duration_min + ' min' : '—';
|
||
var age = (m.age_min && m.age_max) ? m.age_min + '–' + m.age_max + ' J' : '—';
|
||
return '<div class="module-card">' +
|
||
'<div class="module-img">' + img +
|
||
'<div class="status-badge status-' + m.status + '">' + m.status + '</div></div>' +
|
||
'<div class="module-body">' +
|
||
'<div class="module-title"><span class="module-emoji">' + (m.icon || '🧩') + '</span>' +
|
||
'<span class="module-title-text">' + esc(m.title) + '</span></div>' +
|
||
(m.subtitle ? '<div class="module-sub">' + esc(m.subtitle) + '</div>' : '') +
|
||
'<div class="module-meta">' +
|
||
'<span class="meta-chip">🆔 ' + m.module_id + '</span>' +
|
||
'<span class="meta-chip">⏱ ' + dur + '</span>' +
|
||
'<span class="meta-chip">👥 ' + age + '</span>' +
|
||
'</div>' +
|
||
'<div class="module-actions">' +
|
||
'<button class="btn" onclick="editModule(\'' + m.module_id + '\')">Bearbeiten</button>' +
|
||
(m.play_url ? '<a class="btn btn-outline" href="' + m.play_url + '" target="_blank">▶ Testen</a>' : '') +
|
||
'</div>' +
|
||
'</div></div>';
|
||
}).join('');
|
||
}
|
||
|
||
function esc(s) { return (s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]); }
|
||
|
||
function editModule(id) {
|
||
var m = modules.find(x => x.module_id === id);
|
||
if (!m) return;
|
||
editingId = id;
|
||
document.getElementById('f-id').value = m.module_id;
|
||
document.getElementById('f-icon').value = m.icon || '';
|
||
document.getElementById('emoji-preview').textContent = m.icon || '–';
|
||
document.getElementById('edit-emoji-display').textContent = m.icon || '🧩';
|
||
document.getElementById('f-title').value = m.title || '';
|
||
document.getElementById('edit-title-display').textContent = m.title || 'Modul bearbeiten';
|
||
document.getElementById('f-subtitle').value = m.subtitle || '';
|
||
document.getElementById('f-status').value = m.status || 'beta';
|
||
document.getElementById('f-duration').value = m.duration_min || '';
|
||
document.getElementById('f-agemin').value = m.age_min || '';
|
||
document.getElementById('f-agemax').value = m.age_max || '';
|
||
document.getElementById('f-playurl').value = m.play_url || '';
|
||
document.getElementById('f-pageurl').value = m.page_url || '';
|
||
document.getElementById('f-sort').value = m.sort_order || 0;
|
||
|
||
var imgPreview = document.getElementById('current-img-preview');
|
||
var imgName = document.getElementById('current-img-name');
|
||
if (m.card_image) {
|
||
imgPreview.src = 'assets/img/' + m.card_image;
|
||
imgPreview.style.display = '';
|
||
imgName.textContent = m.card_image;
|
||
} else {
|
||
imgPreview.style.display = 'none';
|
||
imgName.textContent = '(kein Bild)';
|
||
}
|
||
document.getElementById('f-image').value = '';
|
||
document.getElementById('edit-msg').innerHTML = '';
|
||
document.getElementById('overlay').classList.add('open');
|
||
|
||
renderEmojiPicker();
|
||
}
|
||
|
||
function renderEmojiPicker() {
|
||
var p = document.getElementById('emoji-picker');
|
||
p.innerHTML = EMOJI_SET.map(e =>
|
||
'<button type="button" class="emoji-pick" onclick="selectEmoji(\'' + e + '\')">' + e + '</button>'
|
||
).join('');
|
||
}
|
||
|
||
function togglePicker() {
|
||
var p = document.getElementById('emoji-picker');
|
||
p.style.display = p.style.display === 'none' ? 'grid' : 'none';
|
||
}
|
||
|
||
function selectEmoji(e) {
|
||
document.getElementById('f-icon').value = e;
|
||
document.getElementById('emoji-preview').textContent = e;
|
||
document.getElementById('edit-emoji-display').textContent = e;
|
||
document.getElementById('emoji-picker').style.display = 'none';
|
||
}
|
||
|
||
function closeEdit() {
|
||
document.getElementById('overlay').classList.remove('open');
|
||
editingId = null;
|
||
}
|
||
|
||
function save() {
|
||
if (!editingId) return;
|
||
var msg = document.getElementById('edit-msg');
|
||
msg.innerHTML = '';
|
||
|
||
var data = {
|
||
module_id: editingId,
|
||
title: document.getElementById('f-title').value.trim(),
|
||
subtitle: document.getElementById('f-subtitle').value.trim(),
|
||
icon: document.getElementById('f-icon').value.trim(),
|
||
status: document.getElementById('f-status').value,
|
||
duration_min: document.getElementById('f-duration').value || null,
|
||
age_min: document.getElementById('f-agemin').value || null,
|
||
age_max: document.getElementById('f-agemax').value || null,
|
||
play_url: document.getElementById('f-playurl').value.trim(),
|
||
page_url: document.getElementById('f-pageurl').value.trim(),
|
||
sort_order: document.getElementById('f-sort').value || 0
|
||
};
|
||
|
||
fetch(BASE + '/api/admin-modules', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(data)
|
||
})
|
||
.then(r => r.json())
|
||
.then(r => {
|
||
if (r.ok) {
|
||
// Wenn Bild hochgeladen: zusätzlich POST
|
||
var file = document.getElementById('f-image').files[0];
|
||
if (file) return uploadImage(file);
|
||
msg.innerHTML = '<div class="msg ok">✓ Gespeichert</div>';
|
||
loadModules();
|
||
setTimeout(closeEdit, 800);
|
||
} else {
|
||
msg.innerHTML = '<div class="msg err">Fehler: ' + (r.error || 'unbekannt') + '</div>';
|
||
}
|
||
})
|
||
.catch(e => { msg.innerHTML = '<div class="msg err">Netzwerkfehler</div>'; });
|
||
}
|
||
|
||
function uploadImage(file) {
|
||
var msg = document.getElementById('edit-msg');
|
||
var fd = new FormData();
|
||
fd.append('module_id', editingId);
|
||
fd.append('image', file);
|
||
return fetch(BASE + '/api/admin-modules', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
body: fd
|
||
})
|
||
.then(r => r.json())
|
||
.then(r => {
|
||
if (r.ok) {
|
||
msg.innerHTML = '<div class="msg ok">✓ Gespeichert inkl. Bild</div>';
|
||
loadModules();
|
||
setTimeout(closeEdit, 800);
|
||
} else {
|
||
msg.innerHTML = '<div class="msg err">Bild-Upload fehlgeschlagen: ' + (r.error || '') + '</div>';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Auth-Check
|
||
fetch(BASE + '/api/admin', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ action: 'status' })
|
||
})
|
||
.then(r => r.json())
|
||
.then(r => {
|
||
if (!r.authenticated) { location.href = 'admin.html'; return; }
|
||
loadModules();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|