Atlas: Live-View-Feature + Submit-Bug-Fixes + Admin-Styleguide

- 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>
This commit is contained in:
2026-05-07 01:20:53 +02:00
parent e354a30a44
commit 33fb423967
56 changed files with 4869 additions and 226 deletions
+146 -73
View File
@@ -13,9 +13,10 @@
.topbar{background:#fff;padding:.55rem 1.2rem;display:flex;align-items:center;gap:.7rem;border-bottom:1px solid rgba(0,0,0,.06);position:sticky;top:0;z-index:10}
.topbar .logo{height:28px}
.topbar .spacer{flex:1}
.topbar .user-pill{display:flex;align-items:center;gap:.35rem;padding:.25rem .6rem .25rem .3rem;background:#dae8ec;border-radius:99px;font-size:.72rem;font-weight:600;color:#4a7c8a;text-decoration:none;cursor:pointer}
.topbar .user-pill{display:flex;align-items:center;gap:.35rem;padding:.15rem .6rem .15rem .15rem;background:#dae8ec;border-radius:99px;font-size:.72rem;font-weight:600;color:#4a7c8a;text-decoration:none;cursor:pointer}
.topbar .user-pill:hover{background:#c8dce2}
.topbar .user-pill .emoji{font-size:1.2rem}
.topbar .user-pill .emoji{font-size:1.2rem;width:28px;height:28px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:#fff;overflow:hidden}
.topbar .user-pill .emoji img{width:100%;height:100%;object-fit:cover;display:block}
.topbar .btn-sm{padding:.25rem .55rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.65rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none}
.topbar .btn-sm:hover{background:#dae8ec;color:#4a7c8a}
@@ -27,20 +28,25 @@
.tab.on{background:#4a7c8a;color:#fff}
/* Module grid */
.mod-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.8rem}
.mod-card{background:#fff;border-radius:12px;overflow:hidden;border:1.5px solid rgba(0,0,0,.05);transition:all .25s;cursor:pointer;text-decoration:none;color:inherit;display:block}
.mod-card:hover{transform:translateY(-3px);box-shadow:0 8px 25px rgba(74,124,138,.1);border-color:rgba(74,124,138,.2)}
.mod-card.locked{opacity:.55;cursor:default}
.mod-card.locked:hover{transform:none;box-shadow:none}
.mod-img{height:110px;background:linear-gradient(135deg,#dae8ec,#c8dce2);display:flex;align-items:center;justify-content:center;font-size:2.5rem;position:relative}
.mod-img img{width:100%;height:100%;object-fit:cover}
.mod-body{padding:.7rem}
.mod-body h3{font-size:.82rem;font-weight:700;margin-bottom:.15rem}
.mod-body p{font-size:.68rem;color:#4a4a4a;margin-bottom:.4rem}
.mod-status{display:inline-flex;align-items:center;gap:.25rem;font-size:.6rem;font-weight:600;padding:2px 8px;border-radius:5px}
.mod-status.available{background:#dceadd;color:#5a8a5e}
.mod-status.locked-s{background:#f0eeea;color:#8a8a8a}
.mod-status.done{background:#dae8ec;color:#4a7c8a}
.mod-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem}
.mod-card{background:#fff;border-radius:12px;overflow:hidden;border:1.5px solid rgba(0,0,0,.05);transition:all .25s;display:flex;flex-direction:column}
.mod-card.spielbar{cursor:default}
.mod-card.locked{opacity:.55}
.mod-card.coming{opacity:.7}
.mod-card.coming .mod-img{filter:grayscale(.5)}
.coming-icon{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:1rem;background:rgba(255,255,255,.92);border-radius:8px;padding:.3rem .55rem;font-weight:700;color:#8a6a3a;white-space:nowrap}
.coming-divider{margin:1.4rem 0 .8rem;font-size:.74rem;font-weight:700;color:#8a8a8a;text-transform:uppercase;letter-spacing:.06em;padding:.4rem .2rem;border-top:1px solid rgba(0,0,0,.06)}
.mod-img{height:210px;background:linear-gradient(135deg,#dae8ec,#c8dce2);display:flex;align-items:center;justify-content:center;font-size:2.5rem;position:relative;overflow:hidden}
.mod-img img{width:100%;height:100%;object-fit:cover;transition:transform .35s}
.mod-card.spielbar:hover .mod-img img{transform:scale(1.04)}
.mod-body{padding:.7rem .8rem .9rem;flex:1;display:flex;flex-direction:column}
.mod-body h3{font-size:.92rem;font-weight:700;margin-bottom:.25rem;line-height:1.2}
.mod-body p{font-size:.72rem;color:#4a4a4a;margin-bottom:.6rem;line-height:1.4;flex:1}
.mod-actions{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:auto}
.mod-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.42rem .7rem;border-radius:7px;font-size:.7rem;font-weight:600;text-decoration:none;border:1.5px solid rgba(0,0,0,.08);background:#f7f6f3;color:#4a4a4a;transition:all .15s;min-height:32px;white-space:nowrap}
.mod-btn:hover{background:#fff;border-color:rgba(74,124,138,.4);color:#1a1a1a}
.mod-btn.primary{background:#4a7c8a;color:#fff;border-color:#4a7c8a;flex:1;justify-content:center}
.mod-btn.primary:hover{background:#3d6671;color:#fff}
.lock-icon{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:1.5rem;background:rgba(255,255,255,.9);border-radius:8px;padding:.3rem .6rem}
/* Erfolge */
@@ -51,6 +57,22 @@
.badge .desc{font-size:.6rem;color:#8a8a8a}
.badge.earned{border-color:#4a7c8a;background:linear-gradient(135deg,#f0f8fa,#fff)}
.badge.locked-b{opacity:.4}
/* Erfolgs-Liste pro Modul */
.ach-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:.6rem;margin-bottom:1.2rem}
.ach-summary .card{background:#fff;border-radius:10px;padding:.9rem .8rem;text-align:center;border:1.5px solid rgba(0,0,0,.05)}
.ach-summary .n{font-size:1.6rem;font-weight:900;color:#4a7c8a;line-height:1}
.ach-summary .l{font-size:.62rem;color:#8a8a8a;margin-top:.25rem;text-transform:uppercase;letter-spacing:.04em}
.ach-modules{display:flex;flex-direction:column;gap:.5rem;margin-bottom:1.4rem}
.ach-mod{background:#fff;border-radius:10px;padding:.7rem .9rem;display:flex;align-items:center;gap:.7rem;border:1.5px solid rgba(0,0,0,.05)}
.ach-mod .ic{font-size:1.5rem;width:36px;text-align:center;flex-shrink:0}
.ach-mod .info{flex:1;min-width:0}
.ach-mod .info h4{font-size:.85rem;font-weight:700;margin:0 0 .15rem 0}
.ach-mod .info .meta{font-size:.65rem;color:#8a8a8a}
.ach-mod .stats{display:flex;gap:.5rem;flex-shrink:0}
.ach-mod .stat-pill{background:#f0eeea;color:#4a4a4a;padding:.2rem .55rem;border-radius:6px;font-size:.62rem;font-weight:700}
.ach-mod .stat-pill.stars{background:#fff4d6;color:#a37800}
.ach-mod .stat-pill.lvl{background:#dae8ec;color:#4a7c8a}
.ach-empty{background:#fff;border-radius:10px;padding:1.5rem 1rem;text-align:center;border:1.5px dashed rgba(0,0,0,.1);color:#8a8a8a;font-size:.78rem}
/* Profil-Karte */
.profile-card{background:#fff;border-radius:12px;padding:1.5rem;text-align:center;border:1.5px solid rgba(0,0,0,.04)}
@@ -68,7 +90,7 @@
</head>
<body>
<div class="topbar">
<img src="assets/img/geograsim_t.svg" alt="GeoGraSim" class="logo">
<a href="schueler" aria-label="Cockpit"><img src="assets/img/geograsim_t.svg" alt="GeoGraSim" class="logo"></a>
<span class="spacer"></span>
<a href="profil.html" class="user-pill" id="user-pill"><span class="emoji">🧑‍🎓</span><span id="user-name"></span></a>
<button class="btn-sm" onclick="logout()">Abmelden</button>
@@ -94,20 +116,9 @@ if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -
var profile = null;
var modules = [];
// Module kommen aus /api/modules?student=1 (inkl. play_url, icon, name, mode).
// card_image + Fallback-Beschreibung ergaenzen wir clientseitig.
var CARD_IMAGES = {
'klima': 'assets/img/card-climate.png',
'klima-3d': 'assets/img/card-climate.png',
'fluss': 'assets/img/card-fluss.png',
'heli': 'assets/img/card-heli.png',
'stadt': 'assets/img/card-city.png',
'erdbeben': 'assets/img/card-tectonic.png',
'energiemix': 'assets/img/card-energy.png',
'lieferketten': 'assets/img/card-globe.png',
'regenwald': 'assets/img/card-climate.png',
'logistik': 'assets/img/card-globe.png'
};
// Module kommen aus /api/modules?student=1 (inkl. play_url, icon, name, mode,
// card_image, desc). Bild-Pfad ist ein Dateiname wie "card-farmer.png" und wird
// gegen "assets/img/" relativ zur App-Basis aufgeloest.
var BADGES = [
{id:'first_sim', icon:'🎯', name:'Erste Simulation', desc:'Eine Simulation abgeschlossen'},
@@ -148,7 +159,14 @@ async function init() {
var pr = await api('profile');
profile = pr.profile;
if (profile?.emoji_avatar) {
document.querySelector('#user-pill .emoji').textContent = profile.emoji_avatar;
var v = profile.emoji_avatar;
var pillEmoji = document.querySelector('#user-pill .emoji');
if (typeof v === 'string' && v.indexOf('avatar:') === 0) {
var slug = v.substring(7);
pillEmoji.innerHTML = '<img src="assets/img/avatars/avatar-' + slug + '.png" alt="">';
} else {
pillEmoji.textContent = v;
}
}
await renderModules();
@@ -175,11 +193,13 @@ async function renderModules() {
if (modList.error) modList = [];
} catch(e) {}
// Lehrer-gesteuerte Auftraege zuerst
var teacherStarted = modList.filter(function(m) { return m.mode === 'teacher_started'; });
// Lehrer-gesteuerte Auftraege zuerst — erledigte Auftraege fallen automatisch
// in die regulaere Modul-Liste, damit der Auftrag aus dem Cockpit verschwindet,
// das Modul aber spielbar bleibt.
var teacherStarted = modList.filter(function(m) { return m.mode === 'teacher_started' && !m.assignmentCompleted; });
if (teacherStarted.length > 0) {
html += '<div style="margin-bottom:1rem">';
html += '<p style="font-size:.78rem;font-weight:700;color:#e8833a;margin-bottom:.4rem">📋 Auftraege von deiner Lehrperson</p>';
html += '<p style="font-size:.78rem;font-weight:700;color:#e8833a;margin-bottom:.4rem">📋 Aufträge von deiner Lehrperson</p>';
teacherStarted.forEach(function(m) {
var due = m.dueDate ? '<br><span style="font-size:.6rem;color:#8a8a8a">Zu erledigen bis ' + new Date(m.dueDate).toLocaleDateString('de-AT',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) + '</span>' : '';
var quiz = m.quizEnabled ? '<br><span style="font-size:.6rem;color:#4a7c8a">📝 Mit kurzem Quiz</span>' : '';
@@ -195,64 +215,113 @@ async function renderModules() {
html += '</div>';
}
// Regulaere Module (free + locked)
html += '<div class="mod-grid">';
modList.filter(function(m) { return m.mode !== 'teacher_started'; }).forEach(function(m) {
// Regulaere Module: spielbar zuerst, "in Kürze" am Ende.
function renderModCard(m) {
var isComing = m.mode === 'coming_soon';
var isLocked = m.mode === 'locked';
var cardImg = CARD_IMAGES[m.id];
var isSpielbar = !isComing && !isLocked;
var cardImg = m.card_image ? 'assets/img/' + m.card_image : null;
var imgHtml = cardImg ? '<img src="'+cardImg+'" alt="">' : '<span style="font-size:3rem">'+(m.icon||'📚')+'</span>';
var url = buildModuleUrl(m);
var moduleId = m.id;
var infoHref = 'modul-' + moduleId;
var glossarHref = 'glossar?module=' + moduleId;
if (isLocked) {
html += '<div class="mod-card locked">';
html += '<div class="mod-img" style="position:relative">'+imgHtml+'<div class="lock-icon">🔒</div></div>';
} else {
html += '<a href="'+url+'" class="mod-card">';
html += '<div class="mod-img">'+imgHtml+'</div>';
}
html += '<div class="mod-body">';
html += '<h3>'+(m.icon||'📚')+' '+m.name+'</h3>';
html += '<p>'+(m.desc||'')+'</p>';
if (isLocked) {
html += '<span class="mod-status locked-s">🔒 Gesperrt</span>';
html += '</div></div>';
} else {
html += '<span class="mod-status available">▶ Starten</span>';
html += '</div></a>';
}
});
var cls = isComing ? 'coming' : (isLocked ? 'locked' : 'spielbar');
var s = '<div class="mod-card '+cls+'">';
var overlay = isComing ? '<div class="coming-icon">🚧 In Kürze</div>'
: isLocked ? '<div class="lock-icon">🔒</div>'
: '';
s += '<div class="mod-img">'+imgHtml+overlay+'</div>';
s += '<div class="mod-body">';
s += '<h3>'+(m.icon||'📚')+' '+m.name+'</h3>';
s += '<p>'+(m.desc||'')+'</p>';
s += '<div class="mod-actions">';
if (isSpielbar) s += '<a href="'+url+'" class="mod-btn primary">▶ Starten</a>';
s += '<a href="'+infoHref+'" class="mod-btn" title="Mehr Infos">📖 Infos</a>';
if (!isComing) s += '<a href="'+glossarHref+'" class="mod-btn" title="Glossar zu diesem Modul">📚 Glossar</a>';
s += '</div>';
s += '</div></div>';
return s;
}
// Regulaere Liste enthaelt alles ausser noch offene Lehrer-Auftraege.
// Erledigte Auftraege erscheinen hier als spielbar, damit der Schueler sie
// freiwillig wiederholen kann.
var regular = modList.filter(function(m) { return m.mode !== 'teacher_started' || m.assignmentCompleted; });
var spielbar = regular.filter(function(m) { return m.mode !== 'coming_soon'; });
var coming = regular.filter(function(m) { return m.mode === 'coming_soon'; });
html += '<div class="mod-grid">';
spielbar.forEach(function(m) { html += renderModCard(m); });
html += '</div>';
if (coming.length > 0) {
html += '<div class="coming-divider">In Kürze verfügbar</div>';
html += '<div class="mod-grid">';
coming.forEach(function(m) { html += renderModCard(m); });
html += '</div>';
}
document.getElementById('tab-modules').innerHTML = html;
}
// Modul-Beschreibungen kommen jetzt aus module_info.short_desc via API.
async function renderAchievements() {
// Lade echte Fortschrittsdaten
var progressData = {};
var allProgress = [];
try {
var allProgress = await api('progress?student_id=0'); // eigener Fortschritt
if (Array.isArray(allProgress)) {
allProgress.forEach(function(p) { progressData[p.sim_id] = p; });
}
var resp = await api('progress?student_id=0');
if (Array.isArray(resp)) allProgress = resp;
} catch(e) {}
var totalPlays = 0, totalStars = 0, totalXP = 0;
Object.values(progressData).forEach(function(p) {
var totalPlays = 0, totalXP = 0, bestStarsOverall = 0;
allProgress.forEach(function(p) {
totalPlays += parseInt(p.plays || 0);
totalStars = Math.max(totalStars, parseInt(p.best_stars || 0));
totalXP += parseInt(p.xp || 0);
bestStarsOverall = Math.max(bestStarsOverall, parseInt(p.best_stars || 0));
});
var simCount = allProgress.length;
var earned = [];
if (totalPlays >= 1) earned.push('first_sim');
if (Object.keys(progressData).length >= 3) earned.push('three_sims');
if (Object.keys(progressData).length >= 8) earned.push('all_sims');
if (totalStars >= 4) earned.push('high_score');
if (totalXP >= 100) earned.push('speed_run'); // repurposed as XP badge
if (simCount >= 3) earned.push('three_sims');
if (simCount >= 8) earned.push('all_sims');
if (bestStarsOverall >= 4) earned.push('high_score');
if (totalXP >= 100) earned.push('speed_run');
var html = '<div style="margin-bottom:.8rem"><p style="font-size:.85rem;font-weight:700;color:#4a7c8a">Deine Erfolge</p>';
html += '<p style="font-size:.72rem;color:#8a8a8a">Spiele Simulationen und sammle Auszeichnungen.</p></div>';
var html = '';
// Top-Kennzahlen
html += '<div class="ach-summary">';
html += '<div class="card"><div class="n">'+simCount+'</div><div class="l">Module begonnen</div></div>';
html += '<div class="card"><div class="n">'+totalPlays+'</div><div class="l">Durchgänge</div></div>';
html += '<div class="card"><div class="n">'+totalXP+'</div><div class="l">XP gesamt</div></div>';
html += '</div>';
// Modul-Erfolge-Liste
html += '<p style="font-size:.85rem;font-weight:700;color:#4a7c8a;margin-bottom:.5rem">Was du in den Modulen geschafft hast</p>';
if (allProgress.length === 0) {
html += '<div class="ach-empty">Noch keine Module gespielt. Geh auf <strong>Meine Module</strong> und starte einen Durchgang.</div>';
} else {
html += '<div class="ach-modules">';
allProgress.forEach(function(p) {
var stars = '★'.repeat(p.best_stars || 0) + '☆'.repeat(Math.max(0, 5 - (p.best_stars || 0)));
var last = p.last_played_at ? new Date(p.last_played_at.replace(' ','T')+'Z').toLocaleDateString('de-AT', {day:'2-digit',month:'2-digit',year:'2-digit'}) : '—';
html += '<div class="ach-mod">';
html += '<div class="ic">'+(p.icon || '📚')+'</div>';
html += '<div class="info"><h4>'+(p.title || p.sim_id)+'</h4>';
html += '<div class="meta">'+p.plays+'× gespielt · '+p.xp+' XP · zuletzt '+last+'</div></div>';
html += '<div class="stats">';
html += '<span class="stat-pill stars" title="Beste Sterne">'+stars+'</span>';
html += '<span class="stat-pill lvl">Lvl '+(p.level || 1)+'</span>';
html += '</div></div>';
});
html += '</div>';
}
// Auszeichnungen / Badges
html += '<p style="font-size:.85rem;font-weight:700;color:#4a7c8a;margin-bottom:.5rem">Auszeichnungen</p>';
html += '<div class="badge-grid">';
BADGES.forEach(function(b) {
var isEarned = earned.indexOf(b.id) !== -1;
@@ -283,7 +352,11 @@ async function renderProfile() {
} catch(e) {}
var html = '<div class="profile-card">';
html += '<div class="profile-avatar" onclick="location.href=\'profil.html\'">'+(profile.emoji_avatar || '🧑‍🎓')+'</div>';
var pAv = profile.emoji_avatar || '🧑‍🎓';
var pAvHtml = (typeof pAv === 'string' && pAv.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/avatar-' + pAv.substring(7) + '.png" alt="" style="width:100%;height:100%;border-radius:50%;object-fit:cover">'
: pAv;
html += '<div class="profile-avatar" onclick="location.href=\'profil.html\'" style="width:80px;height:80px;border-radius:50%;background:#dae8ec;display:flex;align-items:center;justify-content:center;margin:0 auto;overflow:hidden">'+pAvHtml+'</div>';
html += '<div class="profile-name">'+(profile.display_name || profile.username)+'</div>';
html += '<div class="profile-class">Klasse '+(profile.class_name || '—')+'</div>';
html += '<div class="profile-stats">';