Files
geograsim/App/pages/simulationen.php
T
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/
- 12 code-workspace-Files
- STATUS-*.md
- viele M/D/R-Änderungen an bereits getrackten Files
- .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:27:02 +02:00

480 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Simulationen — Einstiegs-Grid
* -----------------------------
* Übersicht aller Module aus `module_info`. Zwei CTAs pro Card:
* "Mehr erfahren" → modul-*.php Detailseite · "Simulation starten" → play_url.
*
* URL: http://localhost/geograsim/App/simulationen
* ?status=aktiv vorgewählter Status-Filter
* ?theme=klima vorgewähltes Kompetenz-Thema
*/
require_once __DIR__ . '/../php/config/app.php';
require_once __DIR__ . '/../php/config/db.php';
require_once __DIR__ . '/../php/lib/EasyLang.php';
require_once __DIR__ . '/../php/lib/Country.php';
$db = getDB();
// Filter aus URL
$qStatus = preg_replace('/[^a-z]/', '', strtolower($_GET['status'] ?? ''));
$qTheme = preg_replace('/[^a-z0-9_-]/', '', strtolower($_GET['theme'] ?? ''));
// Alle Module laden
$modules = $db->query('
SELECT module_id, title, subtitle, short_desc, short_desc_easy,
duration_min, age_min, age_max, icon, card_image,
page_url, play_url, status, available_from, sort_order
FROM module_info
ORDER BY FIELD(status, "aktiv", "beta", "geplant", "archiv"), sort_order, title
')->fetchAll();
// Kompetenz-Zuordnung je Modul (fuer Theme-Chip + Thema-Filter)
$kompMap = [];
$kompQ = $db->query('
SELECT km.module_id, k.key_slug, k.title, k.category, km.strength
FROM kompetenz_modules km
INNER JOIN kompetenzen k ON k.id = km.kompetenz_id
ORDER BY km.strength, k.sort_order
');
foreach ($kompQ as $r) {
$kompMap[$r['module_id']][] = $r;
}
// Alle moeglichen Themen / Kompetenzen einsammeln (Filter-Chips)
$themes = $db->query('SELECT key_slug, title, category FROM kompetenzen ORDER BY sort_order')->fetchAll();
// Zaehlungen pro Status
$counts = ['aktiv' => 0, 'beta' => 0, 'geplant' => 0, 'archiv' => 0];
foreach ($modules as $m) {
$counts[$m['status']] = ($counts[$m['status']] ?? 0) + 1;
}
$bp = BASE_PATH;
$easyActive = EasyLang::isActive();
function esc(?string $s): string { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
$statusMeta = [
'aktiv' => ['label' => 'Aktiv', 'tone' => 'moss', 'icon' => '✓'],
'beta' => ['label' => 'Beta', 'tone' => 'sand', 'icon' => '⚠'],
'geplant' => ['label' => 'In Entw.', 'tone' => 'fjord', 'icon' => '🔒'],
'archiv' => ['label' => 'Archiv', 'tone' => 'muted', 'icon' => '□'],
];
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Simulationen — Lehrplan geograsim.at</title>
<meta name="description" content="Alle GeoGraSim-Simulationen auf einen Blick — Klimawächter, Flussmanagement, Heli-Navigation, Stadt &amp; Raumplanung und weitere in Entwicklung.">
<link rel="icon" type="image/svg+xml" href="<?= $bp ?>/favicon.svg">
<link rel="icon" type="image/png" href="<?= $bp ?>/favicon-96x96.png" sizes="96x96">
<link rel="stylesheet" href="<?= $bp ?>/assets/fonts/inter.css">
<link rel="stylesheet" href="<?= $bp ?>/assets/css/design-system.css">
<style>
body { overflow: auto; height: auto; min-height: 100dvh; background: var(--ggs-bg); }
/* Topbar identisch zu lehrplan.php */
.sp-topbar {
background: var(--ggs-white); padding: .55rem 1.2rem;
display: flex; align-items: center; gap: .8rem;
border-bottom: 1px solid var(--ggs-border);
position: sticky; top: 0; z-index: 50;
}
.sp-topbar a { text-decoration: none; }
.sp-topbar-brand img { height: 40px; display: block; }
.sp-topbar .spacer { flex: 1; }
.sp-btn-sm {
padding: .5rem .85rem; border: 1px solid var(--ggs-border);
border-radius: var(--ggs-radius-sm); background: var(--ggs-white);
font-size: .78rem; font-weight: 600; cursor: pointer;
color: var(--ggs-text); font-family: inherit; text-decoration: none;
display: inline-flex; align-items: center; min-height: 40px;
}
.sp-btn-sm:hover { background: var(--ggs-fjord-light); color: var(--ggs-fjord-dark); border-color: var(--ggs-fjord); }
.sp-btn-sm.sp-btn-primary { background: var(--ggs-fjord); color: var(--ggs-white); border-color: var(--ggs-fjord); }
.sp-easy-pill { background: var(--ggs-moss-light); color: var(--ggs-moss-dark); border-color: var(--ggs-moss); font-weight: 700; }
/* Hero */
.sp-hero {
background: linear-gradient(135deg, var(--ggs-fjord-dark) 0%, var(--ggs-fjord) 100%);
color: var(--ggs-white); padding: 2.5rem 1.5rem 3rem; text-align: center;
}
.sp-hero h1 { font-size: clamp(1.8rem, 4vw, 2.6rem); font-weight: 900; letter-spacing: -.03em; margin-bottom: .5rem; }
.sp-hero p { font-size: 1.02rem; color: rgba(255,255,255,.88); max-width: 640px; margin: 0 auto 1.4rem; }
.sp-hero-stats { display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap; margin-top: 1.2rem; }
.sp-hero-stat { text-align: center; }
.sp-hero-stat-num { font-size: 1.8rem; font-weight: 900; display: block; }
.sp-hero-stat-lbl { font-size: .72rem; opacity: .8; text-transform: uppercase; letter-spacing: .08em; }
/* Filter-Bar */
.sp-filters {
max-width: 1200px; margin: -1.4rem auto 1.5rem;
padding: 1rem 1.2rem; background: var(--ggs-white);
border-radius: var(--ggs-radius-lg); box-shadow: var(--ggs-shadow-lg);
border: 1px solid var(--ggs-border);
position: relative; z-index: 5;
display: flex; flex-direction: column; gap: .9rem;
}
.sp-filter-row { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem; }
.sp-filter-label {
font-size: .72rem; font-weight: 800; text-transform: uppercase;
letter-spacing: .08em; color: var(--ggs-text-muted); min-width: 60px;
}
.sp-chip {
display: inline-flex; align-items: center; gap: .3rem;
padding: .55rem 1rem; border-radius: var(--ggs-radius-pill);
background: var(--ggs-white); border: 1.5px solid var(--ggs-border);
font-size: .88rem; font-weight: 600; color: var(--ggs-text-muted);
cursor: pointer; transition: all .2s var(--ggs-ease); font-family: inherit;
min-height: 40px; min-width: 40px;
}
.sp-chip:hover { border-color: var(--ggs-fjord); color: var(--ggs-fjord); }
.sp-chip:active { transform: scale(0.96); }
.sp-chip.active { background: var(--ggs-fjord); color: var(--ggs-white); border-color: var(--ggs-fjord); }
.sp-chip-badge {
font-size: .7rem; padding: 1px 6px; border-radius: var(--ggs-radius-pill);
background: var(--ggs-bg-dark); color: var(--ggs-text-muted); font-weight: 700;
}
.sp-chip.active .sp-chip-badge { background: rgba(255,255,255,.22); color: var(--ggs-white); }
/* Grid */
.sp-wrap { max-width: 1200px; margin: 0 auto 3rem; padding: 0 1.2rem; }
.sp-results-info {
font-size: .82rem; color: var(--ggs-text-muted);
margin-bottom: 1rem;
}
.sp-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.4rem;
}
/* Modul-Card */
.sp-card {
background: var(--ggs-white); border: 1px solid var(--ggs-border);
border-radius: var(--ggs-radius-lg); overflow: hidden;
display: flex; flex-direction: column;
transition: transform .25s var(--ggs-ease), box-shadow .25s var(--ggs-ease),
border-color .25s var(--ggs-ease);
box-shadow: var(--ggs-shadow-sm);
}
.sp-card:hover { transform: translateY(-3px); box-shadow: var(--ggs-shadow-md);
border-color: var(--ggs-fjord-light); }
.sp-card.sp-card-geplant { opacity: .72; filter: grayscale(.55); transition: filter .25s, opacity .25s; }
.sp-card.sp-card-geplant:hover { opacity: .92; filter: grayscale(.15); }
.sp-card.sp-card-geplant .sp-pill-pending { filter: grayscale(0); opacity: 1; }
.sp-card-media {
aspect-ratio: 16 / 10; overflow: hidden; position: relative;
background: var(--ggs-fjord-light);
}
.sp-card-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
.sp-card-badge {
position: absolute; top: 10px; right: 10px;
font-size: .7rem; font-weight: 700; padding: 4px 10px;
border-radius: var(--ggs-radius-pill); letter-spacing: .04em;
text-transform: uppercase;
}
.sp-card-badge.moss { background: var(--ggs-moss); color: var(--ggs-white); }
.sp-card-badge.sand { background: var(--ggs-sand-dark); color: var(--ggs-white); }
.sp-card-badge.fjord { background: var(--ggs-fjord); color: var(--ggs-white); opacity: .85; }
.sp-card-badge.muted { background: var(--ggs-text-muted); color: var(--ggs-white); }
.sp-pill-pending {
display: inline-flex; align-items: center; gap: .4rem;
background: var(--ggs-sand-light, #f5efd9); color: var(--ggs-fjord-dark);
border: 1px dashed var(--ggs-sand-dark, #d4b76a);
font-size: .82rem; font-weight: 700; padding: .55rem .9rem;
border-radius: var(--ggs-radius-pill); letter-spacing: .01em;
}
.sp-card-body {
padding: 1rem 1.2rem 1.2rem; display: flex; flex-direction: column; gap: .6rem;
flex: 1;
}
.sp-card-title {
font-size: 1.2rem; font-weight: 800; color: var(--ggs-fjord-dark);
letter-spacing: -.02em; display: flex; align-items: center; gap: .4rem;
line-height: 1.2;
}
.sp-card-icon { font-size: 1.4rem; line-height: 1; }
.sp-card-subtitle {
font-size: .88rem; color: var(--ggs-text); line-height: 1.5;
flex: 1;
}
.sp-card-meta {
display: flex; flex-wrap: wrap; gap: .4rem;
margin-top: .3rem;
}
.sp-card-meta-item {
font-size: .72rem; color: var(--ggs-text-muted);
background: var(--ggs-bg); padding: 3px 9px;
border-radius: var(--ggs-radius-pill);
display: inline-flex; align-items: center; gap: .25rem;
}
.sp-card-themes {
display: flex; flex-wrap: wrap; gap: .3rem;
margin-top: .2rem;
}
.sp-card-theme {
font-size: .7rem; color: var(--ggs-fjord-dark);
background: var(--ggs-fjord-light); padding: 2px 8px;
border-radius: var(--ggs-radius-pill); font-weight: 600;
}
.sp-card-cta {
display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .4rem;
}
.sp-btn {
display: inline-flex; align-items: center; justify-content: center;
gap: .35rem; padding: .7rem 1rem;
border-radius: var(--ggs-radius-sm);
font-weight: 700; font-size: .88rem;
text-decoration: none; border: 1.5px solid transparent;
transition: all .2s var(--ggs-ease); min-height: 44px;
flex: 1; text-align: center;
}
.sp-btn-primary { background: var(--ggs-fjord); color: var(--ggs-white); }
.sp-btn-primary:hover { background: var(--ggs-fjord-dark); }
.sp-btn-primary:active { transform: scale(0.98); }
.sp-btn-secondary {
background: var(--ggs-white); color: var(--ggs-fjord);
border-color: var(--ggs-fjord);
}
.sp-btn-secondary:hover { background: var(--ggs-fjord-light); }
.sp-btn-secondary:active { transform: scale(0.98); }
.sp-btn-disabled {
background: var(--ggs-bg); color: var(--ggs-text-muted);
border-color: var(--ggs-border); cursor: not-allowed;
}
.sp-empty {
grid-column: 1 / -1;
text-align: center; padding: 3rem 1rem;
color: var(--ggs-text-muted); font-style: italic;
}
/* Footer */
.sp-footer {
text-align: center; padding: 2rem 1rem 2.5rem;
font-size: .78rem; color: var(--ggs-text-muted);
border-top: 1px solid var(--ggs-border);
background: var(--ggs-white); margin-top: 2rem;
}
.sp-footer img { height: 36px; opacity: .5; margin-bottom: .5rem; }
.sp-footer a { color: var(--ggs-fjord); text-decoration: none; padding: .3rem .2rem; }
@media (max-width: 900px) {
.sp-filter-row { flex-direction: column; align-items: flex-start; gap: .3rem; }
.sp-filter-label { min-width: 0; }
.sp-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<?php
/* Standard-Hauptnav + Kontext-Sub-Nav (N.5.1 — Simulationen-Übersicht ohne Country-Picker,
weil Sims country-agnostisch sind) */
$activeMain = 'sims';
$activeSub = 'simulationen';
$showCountryPicker = false;
include __DIR__ . '/_partials/public_topnav.php';
include __DIR__ . '/_partials/erkundungs_subnav.php';
?>
<section class="sp-hero">
<h1>Alle Simulationen</h1>
<p>
Zu jedem Modul gibt es eine Detailseite mit Lernzielen, Glossar-Begriffen
und Lehrplan-Bezug für Österreich, Deutschland, Schweiz und Liechtenstein.
Zum Starten brauchst du einen Klassen-Zugang von deiner Lehrperson —
<a href="<?= $bp ?>/login.html" style="color:#fff;text-decoration:underline;font-weight:700">jetzt anmelden</a>
oder das Lehrer-Cockpit ausprobieren.
</p>
<div class="sp-hero-stats">
<div class="sp-hero-stat"><span class="sp-hero-stat-num"><?= count($modules) ?></span><span class="sp-hero-stat-lbl">Module</span></div>
<div class="sp-hero-stat"><span class="sp-hero-stat-num"><?= $counts['aktiv'] ?></span><span class="sp-hero-stat-lbl">Aktiv</span></div>
<div class="sp-hero-stat"><span class="sp-hero-stat-num"><?= $counts['geplant'] ?></span><span class="sp-hero-stat-lbl">In Entwicklung</span></div>
</div>
</section>
<div class="sp-filters" id="sp-filters">
<div class="sp-filter-row">
<span class="sp-filter-label">Status</span>
<button type="button" class="sp-chip<?= !$qStatus ? ' active' : '' ?>" data-filter="status" data-value="">
Alle <span class="sp-chip-badge"><?= count($modules) ?></span>
</button>
<?php foreach (['aktiv','beta','geplant'] as $s):
if (!($counts[$s] ?? 0)) continue; ?>
<button type="button" class="sp-chip<?= $qStatus === $s ? ' active' : '' ?>"
data-filter="status" data-value="<?= esc($s) ?>">
<?= esc($statusMeta[$s]['label']) ?>
<span class="sp-chip-badge"><?= (int)$counts[$s] ?></span>
</button>
<?php endforeach; ?>
</div>
<div class="sp-filter-row">
<span class="sp-filter-label">Thema</span>
<button type="button" class="sp-chip<?= !$qTheme ? ' active' : '' ?>" data-filter="theme" data-value="">
Alle
</button>
<?php foreach ($themes as $t): ?>
<button type="button" class="sp-chip<?= $qTheme === $t['key_slug'] ? ' active' : '' ?>"
data-filter="theme" data-value="<?= esc($t['key_slug']) ?>">
<?= esc($t['title']) ?>
</button>
<?php endforeach; ?>
</div>
</div>
<div class="sp-wrap">
<div class="sp-results-info" id="sp-results-info"></div>
<div class="sp-grid" id="sp-grid">
<?php foreach ($modules as $m):
$shortText = EasyLang::pick($m['short_desc'] ?? null, $m['short_desc_easy'] ?? null);
$displayText = $shortText ?: ($m['subtitle'] ?? '');
$status = $m['status'];
$statusInfo = $statusMeta[$status] ?? $statusMeta['beta'];
$themesForModule = $kompMap[$m['module_id']] ?? [];
$themeSlugs = array_map(fn($r) => $r['key_slug'], $themesForModule);
$themeAttr = implode(',', $themeSlugs);
$canPlay = in_array($status, ['aktiv','beta'], true);
$isPending = !empty($m['available_from']) && $m['available_from'] > date('Y-m-d');
$pendingDate = '';
if ($isPending) {
$ts = strtotime($m['available_from']);
$pendingDate = date('d.m.', $ts);
}
?>
<article class="sp-card sp-card-<?= esc($status) ?>"
data-status="<?= esc($status) ?>"
data-themes="<?= esc($themeAttr) ?>">
<div class="sp-card-media">
<?php if (!empty($m['card_image'])): ?>
<img src="<?= $bp ?>/assets/img/<?= esc($m['card_image']) ?>" alt="<?= esc($m['title']) ?>">
<?php endif; ?>
<span class="sp-card-badge <?= esc($statusInfo['tone']) ?>">
<?= esc($statusInfo['icon']) ?> <?= esc($statusInfo['label']) ?>
</span>
</div>
<div class="sp-card-body">
<h2 class="sp-card-title">
<span class="sp-card-icon"><?= esc($m['icon']) ?></span>
<?= esc($m['title']) ?>
</h2>
<p class="sp-card-subtitle"><?= esc($displayText) ?></p>
<div class="sp-card-meta">
<?php if (!empty($m['duration_min'])): ?>
<span class="sp-card-meta-item">⏱ ca. <?= (int)$m['duration_min'] ?> Min</span>
<?php endif; ?>
<?php if (!empty($m['age_min']) && !empty($m['age_max'])): ?>
<span class="sp-card-meta-item">👧 <?= (int)$m['age_min'] ?><?= (int)$m['age_max'] ?> J</span>
<?php endif; ?>
</div>
<?php if (!empty($themesForModule)): ?>
<div class="sp-card-themes">
<?php foreach ($themesForModule as $t): ?>
<span class="sp-card-theme" title="Kompetenzbereich"><?= esc($t['title']) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="sp-card-cta">
<?php if ($isPending): ?>
<span class="sp-pill-pending">🗓 ab <?= esc($pendingDate) ?> verfügbar!</span>
<?php else: ?>
<?php if (!empty($m['page_url'])): ?>
<a class="sp-btn sp-btn-secondary" href="<?= $bp ?>/<?= esc($m['page_url']) ?>">
📖 Mehr erfahren
</a>
<?php endif; ?>
<?php if ($canPlay): ?>
<a class="sp-btn sp-btn-primary" href="<?= $bp ?>/login.html"
title="Anmelden, um diese Simulation mit einer Lehrperson zu nutzen">
🔑 Mit Lehrperson nutzen
</a>
<?php else: ?>
<span class="sp-btn sp-btn-disabled" aria-disabled="true">
🔒 In Entwicklung
</span>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
</article>
<?php endforeach; ?>
</div>
</div>
<footer class="sp-footer">
<img src="<?= $bp ?>/assets/img/bildLogo.png" alt="">
<div>
Lehrplan geograsim.at ·
<a href="<?= $bp ?>/pages/lehrplan.php">Lehrplan</a> ·
<a href="<?= $bp ?>/glossar">Glossar</a> ·
<a href="<?= $bp ?>/ueber.html">Über</a> ·
<a href="<?= $bp ?>/impressum.html">Impressum</a> ·
<a href="<?= $bp ?>/datenschutz.html">Datenschutz</a>
</div>
<?php include __DIR__ . '/_partials/country_footer.php'; ?>
</footer>
<script>
(function () {
const bar = document.getElementById('sp-filters');
const info = document.getElementById('sp-results-info');
const cards = document.querySelectorAll('.sp-card');
const total = cards.length;
const state = {
status: <?= json_encode($qStatus) ?>,
theme: <?= json_encode($qTheme) ?>,
};
bar.addEventListener('click', (e) => {
const btn = e.target.closest('.sp-chip');
if (!btn) return;
const f = btn.dataset.filter;
const v = btn.dataset.value;
state[f] = v;
bar.querySelectorAll(`.sp-chip[data-filter="${f}"]`).forEach(c => {
c.classList.toggle('active', c.dataset.value === v);
});
apply();
const params = new URLSearchParams();
if (state.status) params.set('status', state.status);
if (state.theme) params.set('theme', state.theme);
const q = params.toString();
history.replaceState(null, '', q ? '?' + q : location.pathname);
});
function apply() {
let shown = 0;
cards.forEach(c => {
let hide = false;
if (state.status && c.dataset.status !== state.status) hide = true;
if (state.theme) {
const ts = (c.dataset.themes || '').split(',').filter(Boolean);
if (!ts.includes(state.theme)) hide = true;
}
c.style.display = hide ? 'none' : '';
if (!hide) shown++;
});
if (shown === total && !state.status && !state.theme) {
info.textContent = '';
} else {
info.textContent = `${shown} von ${total} Modulen sichtbar`;
}
}
apply();
})();
</script>
</body>
</html>