Lehrplan: Country-Helper + Landeinstellung live

- App/php/lib/Country.php: Kaskade session → classes → teachers →
  cookie → 'AT', Helper current()/flag()/label()/allCountries()
- App/php/api/country.php: GET + POST /api/country
- country-Spalten in teachers, classes, student_sessions (idempotent)
- Native-<select>-Picker im Header aller öffentlichen Seiten
  (modul-*.php, lehrplan.php, simulationen.php)
- Footer-Links "Andere Länder: 🇨🇭 🇩🇪 🇱🇮"
- lehrplan.php liest Default-Filter jetzt aus Country::current()
- iPad-freundlich: Native Select gibt Wheel-Picker auf iOS,
  Touch-Ziel >=40px

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 00:09:43 +02:00
parent db9ed43407
commit 7deec2a26d
8 changed files with 390 additions and 4 deletions
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* Country-Footer — „Andere Länder: 🇨🇭 / 🇩🇪 / 🇱🇮"
* --------------------------------------------------
* Kleine Zeile unten in den öffentlichen Seiten. Zeigt die jeweils
* *nicht* aktuell gewählten Länder mit Flaggen-Link.
*/
require_once __DIR__ . '/../../php/lib/Country.php';
$current = Country::current();
$others = array_filter(Country::allCountries(), fn($c) => $c !== $current);
?>
<div class="md-country-foot">
Andere Länder:
<?php foreach ($others as $c): ?>
<a href="?country=<?= htmlspecialchars($c) ?>"
onclick="event.preventDefault(); window.__GGS_setCountry('<?= htmlspecialchars($c) ?>');"
class="md-country-foot-link"
title="<?= htmlspecialchars(Country::label($c)) ?>">
<?= Country::flag($c) ?> <?= htmlspecialchars(Country::label($c)) ?>
</a>
<?php endforeach; ?>
</div>
<style>
.md-country-foot {
font-size: .78rem; color: var(--ggs-text-muted);
padding: .6rem 0 0;
text-align: center;
}
.md-country-foot a {
display: inline-block; margin: 0 .3rem; padding: .3rem .5rem;
color: var(--ggs-fjord); text-decoration: none;
border-radius: var(--ggs-radius-sm);
min-height: 32px;
}
.md-country-foot a:hover { background: var(--ggs-fjord-light); }
</style>
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* Country-Picker — Native <select> im Header
* -------------------------------------------
* Wird per include in den Modul-/Lehrplan-/Simulationen-Seiten eingebunden.
*
* Erwartet im eltern-Scope:
* $bp BASE_PATH
*
* Beim Ändern:
* - POST auf /php/api/country.php (Session + Cookie)
* - Fallback: Cookie via JS
* - Seite wird neu geladen, damit alle Server-seitigen Filter greifen
*/
require_once __DIR__ . '/../../php/lib/Country.php';
if (!isset($bp)) $bp = defined('BASE_PATH') ? BASE_PATH : '';
$currentCountry = Country::current();
?>
<form class="md-country-form" method="post" action="#" onsubmit="return false;" aria-label="Land auswählen">
<label for="md-country-select" class="md-country-label">
<?= Country::flag($currentCountry) ?>
</label>
<select id="md-country-select" class="md-country-select"
onchange="window.__GGS_setCountry(this.value)">
<?php foreach (Country::allCountries() as $code): ?>
<option value="<?= htmlspecialchars($code) ?>"
<?= $code === $currentCountry ? 'selected' : '' ?>>
<?= Country::flag($code) ?> <?= Country::label($code) ?>
</option>
<?php endforeach; ?>
</select>
</form>
<style>
/* Native-Select iPad-freundlich gestylt */
.md-country-form {
display: inline-flex; align-items: center; gap: .3rem;
background: var(--ggs-white); border: 1px solid var(--ggs-border);
border-radius: var(--ggs-radius-sm);
padding: 0 .5rem 0 .75rem; min-height: 40px;
}
.md-country-form:hover, .md-country-form:focus-within {
border-color: var(--ggs-fjord);
}
.md-country-label {
font-size: 1.1rem; line-height: 1; pointer-events: none;
}
.md-country-select {
appearance: auto; /* native Wheel-Picker auf iOS */
-webkit-appearance: auto;
background: transparent; border: 0; cursor: pointer;
font-size: .85rem; font-weight: 600; font-family: inherit;
color: var(--ggs-fjord-dark);
padding: .5rem .2rem; min-height: 40px;
}
.md-country-select:focus { outline: none; }
</style>
<script>
/*
* window.__GGS_setCountry(code)
*
* 1. POST an /php/api/country.php (falls verfügbar) — persistiert Session + Cookie.
* 2. Zusätzlich lokalen Cookie setzen (Fallback, wenn API-Call scheitert).
* 3. Seite reloaden, damit Server-Filter greifen (Lehrplan-Default etc.).
*/
if (typeof window.__GGS_setCountry !== 'function') {
window.__GGS_setCountry = function (code) {
try {
// Local Cookie als sofortiger Fallback (1 Jahr)
document.cookie = 'ggs-country=' + encodeURIComponent(code)
+ '; Max-Age=' + (60*60*24*365)
+ '; Path=<?= $bp ?: "/" ?>; SameSite=Lax';
} catch (e) {}
// Server-Speicherung
fetch('<?= $bp ?>/api/country', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ country: code })
}).catch(function () {
// Netz weg, kein Problem — Cookie reicht als Fallback
}).finally(function () {
// Seite neu laden, damit Server-Filter (z.B. lehrplan.php ?country=...) greifen
// Wenn in URL bereits ?country=... steht, diesen Parameter überschreiben
var u = new URL(window.location.href);
u.searchParams.set('country', code);
window.location.href = u.toString();
});
};
}
</script>
+2
View File
@@ -404,6 +404,7 @@ $countryMeta = [
<?php if ($easyActive): ?>
<span class="md-btn-sm md-easy-pill" title="Leichte Sprache ist aktiv">🟢 Leichte Sprache</span>
<?php endif; ?>
<?php include __DIR__ . '/country_picker.php'; ?>
<a href="<?= $bp ?>/pages/simulationen.php" class="md-btn-sm">Alle Simulationen</a>
<a href="<?= $bp ?>/pages/lehrplan.php" class="md-btn-sm">Lehrplan</a>
<a href="<?= $bp ?>/glossar" class="md-btn-sm">Glossar</a>
@@ -631,6 +632,7 @@ $countryMeta = [
<a href="<?= $bp ?>/impressum.html">Impressum</a> ·
<a href="<?= $bp ?>/datenschutz.html">Datenschutz</a>
</div>
<?php include __DIR__ . '/country_footer.php'; ?>
</footer>
<script>
+11 -4
View File
@@ -15,14 +15,19 @@
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 (sanft, nur Whitelist-Werte)
// Filter: Default ist das aktuelle Landes-Setting (Kaskade), per URL überschreibbar
$allowedCountries = ['AT', 'DE', 'CH', 'LI'];
$qCountry = strtoupper(trim($_GET['country'] ?? 'AT'));
if (!in_array($qCountry, $allowedCountries, true) && $qCountry !== 'ALL') {
$qCountry = 'AT'; // Default
if (isset($_GET['country'])) {
$qCountry = strtoupper(trim($_GET['country']));
if (!in_array($qCountry, $allowedCountries, true) && $qCountry !== 'ALL') {
$qCountry = Country::current();
}
} else {
$qCountry = Country::current();
}
$qTheme = preg_replace('/[^a-z0-9_-]/', '', strtolower($_GET['theme'] ?? ''));
@@ -359,6 +364,7 @@ $categoryMeta = [
<?php if ($easyActive): ?>
<span class="lp-btn-sm lp-easy-pill">🟢 Leichte Sprache</span>
<?php endif; ?>
<?php include __DIR__ . '/_partials/country_picker.php'; ?>
<a href="<?= $bp ?>/pages/simulationen.php" class="lp-btn-sm">Simulationen</a>
<a href="<?= $bp ?>/glossar" class="lp-btn-sm">Glossar</a>
<a href="<?= $bp ?>/login.html" class="lp-btn-sm lp-btn-primary">Anmelden</a>
@@ -557,6 +563,7 @@ $categoryMeta = [
<a href="<?= $bp ?>/impressum.html">Impressum</a> ·
<a href="<?= $bp ?>/datenschutz.html">Datenschutz</a>
</div>
<?php include __DIR__ . '/_partials/country_footer.php'; ?>
</footer>
<script>
+3
View File
@@ -13,6 +13,7 @@
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();
@@ -270,6 +271,7 @@ $statusMeta = [
<?php if ($easyActive): ?>
<span class="sp-btn-sm sp-easy-pill">🟢 Leichte Sprache</span>
<?php endif; ?>
<?php include __DIR__ . '/_partials/country_picker.php'; ?>
<a href="<?= $bp ?>/pages/lehrplan.php" class="sp-btn-sm">Lehrplan</a>
<a href="<?= $bp ?>/glossar" class="sp-btn-sm">Glossar</a>
<a href="<?= $bp ?>/login.html" class="sp-btn-sm sp-btn-primary">Anmelden</a>
@@ -400,6 +402,7 @@ $statusMeta = [
<a href="<?= $bp ?>/impressum.html">Impressum</a> ·
<a href="<?= $bp ?>/datenschutz.html">Datenschutz</a>
</div>
<?php include __DIR__ . '/_partials/country_footer.php'; ?>
</footer>
<script>
+52
View File
@@ -0,0 +1,52 @@
<?php
/**
* Country API
* -----------
* GET /api/country → aktuelles Land JSON { country: "AT" }
* POST /api/country → setzt das aktuelle Land
* Body: { "country": "DE" }
* Persistiert in Session + Cookie.
*
* Absichtlich leicht gehalten: Die DB-Felder (teachers/classes/students)
* werden *nicht* von dieser API geschrieben — das bleibt dem Profil-Editor
* der Lehrperson und dem Admin überlassen. Hier nur der Kurzzeit-Override.
*/
require_once __DIR__ . '/../config/app.php';
require_once __DIR__ . '/../lib/Country.php';
header('Content-Type: application/json; charset=utf-8');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
echo json_encode([
'country' => Country::current(),
'flag' => Country::flag(Country::current()),
'label' => Country::label(Country::current()),
], JSON_UNESCAPED_UNICODE);
exit;
}
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input') ?: '{}', true) ?: [];
$code = strtoupper((string)($body['country'] ?? $_POST['country'] ?? ''));
if (!Country::isValid($code)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid country code. Expected AT, DE, CH or LI.']);
exit;
}
Country::setCurrent($code);
echo json_encode([
'country' => Country::current(),
'flag' => Country::flag(Country::current()),
'label' => Country::label(Country::current()),
'ok' => true,
], JSON_UNESCAPED_UNICODE);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* Country — Landeinstellung (AT / DE / CH / LI)
* ---------------------------------------------
* Ermittelt das aktuelle Land für Rendering-Entscheidungen (Lehrplan-Filter,
* Inhalts-Priorisierung, Footer-Länderlinks). Auflösungs-Kaskade:
*
* 1. Explizites `?country=AT` in der URL (höchste Priorität, zum Testen)
* 2. Session-Override (über setCurrent() gesetzt)
* 3. `student_sessions.country` (Schüler:in aktiv)
* 4. `classes.country` (von Klasse erben)
* 5. `teachers.country` (Lehrperson eingeloggt)
* 6. Cookie `ggs-country` (Client-Setting für Gast)
* 7. 'AT' Default
*
* Beispiel:
* $country = Country::current(); // 'AT'
* echo Country::flag('DE'); // 🇩🇪
* echo Country::label('CH'); // Schweiz
* foreach (Country::allCountries() as $c) ...
*/
require_once __DIR__ . '/../config/db.php';
class Country
{
public const DEFAULT = 'AT';
private static ?string $cached = null;
private const META = [
'AT' => ['label' => 'Österreich', 'flag' => '🇦🇹'],
'DE' => ['label' => 'Deutschland', 'flag' => '🇩🇪'],
'CH' => ['label' => 'Schweiz', 'flag' => '🇨🇭'],
'LI' => ['label' => 'Liechtenstein','flag' => '🇱🇮'],
];
public static function allCountries(): array
{
return array_keys(self::META);
}
public static function isValid(?string $code): bool
{
return is_string($code) && isset(self::META[strtoupper($code)]);
}
public static function flag(string $code): string
{
return self::META[strtoupper($code)]['flag'] ?? '🏳️';
}
public static function label(string $code): string
{
return self::META[strtoupper($code)]['label'] ?? $code;
}
/**
* Liefert das aktuell gültige Land nach der Kaskade.
* Cached pro Request.
*/
public static function current(): string
{
if (self::$cached !== null) return self::$cached;
// 1. URL-Override (?country=AT)
if (!empty($_GET['country']) && self::isValid($_GET['country'])) {
return self::$cached = strtoupper($_GET['country']);
}
if (session_status() === PHP_SESSION_NONE) @session_start();
// 2. Session-Override (gezielt gesetzt, hält während Session)
if (!empty($_SESSION['ggs_country']) && self::isValid($_SESSION['ggs_country'])) {
return self::$cached = strtoupper($_SESSION['ggs_country']);
}
// 3-5. DB-Kaskade
try {
$db = getDB();
// 3. Schüler:in aktiv?
$studentId = $_SESSION['student_id'] ?? null;
if ($studentId) {
$row = self::fetchFirst($db, '
SELECT s.country AS s_country,
c.country AS c_country,
t.country AS t_country
FROM students s
LEFT JOIN classes c ON c.id = s.class_id
LEFT JOIN teachers t ON t.id = c.teacher_id
WHERE s.id = ? AND s.deleted_at IS NULL
LIMIT 1
', [$studentId]);
if ($row) {
foreach (['s_country','c_country','t_country'] as $f) {
if (!empty($row[$f]) && self::isValid($row[$f])) {
return self::$cached = strtoupper($row[$f]);
}
}
}
}
// 4./5. Lehrperson eingeloggt?
$teacherId = $_SESSION['teacher_id'] ?? null;
if ($teacherId) {
$val = self::fetchFirst($db,
'SELECT country FROM teachers WHERE id = ? LIMIT 1',
[$teacherId]
);
if ($val && !empty($val['country']) && self::isValid($val['country'])) {
return self::$cached = strtoupper($val['country']);
}
}
} catch (Throwable $e) {
// DB-Fehler → Cookie/Default weiter probieren
}
// 6. Cookie (Gast-Setting)
if (!empty($_COOKIE['ggs-country']) && self::isValid($_COOKIE['ggs-country'])) {
return self::$cached = strtoupper($_COOKIE['ggs-country']);
}
// 7. Default
return self::$cached = self::DEFAULT;
}
/**
* Setzt das aktuelle Land. Persistiert:
* - Eingeloggter Schüler → students_sessions.country? Nein, wir nehmen `students.country`?
* Aktuell: Session-Speicher + Cookie, damit die Einstellung nicht sofort die
* DB verändert. DB-Schreiben bewusst dem Admin/Profile überlassen.
* - Für Lehrperson: dto.
* - Gast: nur Cookie
*
* Setzt zusätzlich `$_SESSION['ggs_country']` und einen Cookie für 1 Jahr.
*/
public static function setCurrent(string $code): bool
{
if (!self::isValid($code)) return false;
$code = strtoupper($code);
if (session_status() === PHP_SESSION_NONE) @session_start();
$_SESSION['ggs_country'] = $code;
// Cookie 1 Jahr, SameSite=Lax, Path=BASE_PATH
$path = defined('BASE_PATH') && BASE_PATH !== '' ? BASE_PATH : '/';
@setcookie('ggs-country', $code, [
'expires' => time() + 60*60*24*365,
'path' => $path,
'samesite' => 'Lax',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => false, // Client-JS darf es auch lesen (symmetrischer Fallback)
]);
self::$cached = $code;
return true;
}
/** Nur Internes Helpergriff — fetch one Row */
private static function fetchFirst(PDO $db, string $sql, array $params): ?array
{
$stmt = $db->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
}
+23
View File
@@ -23,6 +23,29 @@ $db = getDB();
echo "=== Content-Architektur Seed ===\n";
// ------------------------------------------------------------------
// 0. Country-Spalten (Landeinstellung-Kaskade) idempotent ergaenzen
// ------------------------------------------------------------------
// Auflösung: students.country? (optional) → classes → teachers → Cookie → AT
// Schreibend nutzt nur Profil-Admin; Lese-Resolver in App/php/lib/Country.php
$countryTargets = [
'teachers' => 'country CHAR(2) NULL DEFAULT NULL AFTER email',
'classes' => 'country CHAR(2) NULL DEFAULT NULL',
'student_sessions' => 'country CHAR(2) NULL DEFAULT NULL',
];
foreach ($countryTargets as $tbl => $def) {
try {
$exists = $db->query("SHOW COLUMNS FROM `$tbl` LIKE 'country'")->fetch();
if (!$exists) {
$db->exec("ALTER TABLE `$tbl` ADD COLUMN $def");
echo " $tbl: Spalte country ergaenzt\n";
}
} catch (Throwable $e) {
echo " $tbl: Hinweis — " . $e->getMessage() . "\n";
}
}
// ------------------------------------------------------------------
// 1. Tabellen
// ------------------------------------------------------------------