feat: Add WFW-Aushang web app with PWA support, offline caching, and dark mode

- Created index.html for the main application interface with responsive design and dark mode support.
- Added manifest.webmanifest for PWA configuration, including app icons and display settings.
- Implemented service worker (sw.js) for offline caching of assets and network-first strategy for versioning.
- Introduced version.json to manage app versioning.
This commit is contained in:
thrhymes
2025-12-24 16:59:51 +01:00
parent 5577407f97
commit b049dded72
24 changed files with 4167 additions and 0 deletions

258
index.php Normal file
View File

@@ -0,0 +1,258 @@
<?php
// index.php Basis-Menü + Login mit flexibler Config (YAML/JSON/INI)
session_start();
// ===== Pfade / Basis =====
$basePath = __DIR__;
$baseUrl = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/\\');
// ===== Config-Lader =====
function find_config_path(string $basePath): ?string {
// Reihenfolge der Dateitypen: YAML, YML, JSON, INI
$candidates = [
$basePath . '/config/config.yaml',
$basePath . '/config/config.yml',
$basePath . '/config/config.json',
$basePath . '/config/config.ini',
];
foreach ($candidates as $c) {
if (@is_file($c) && @is_readable($c)) return $c;
}
return null;
}
function parse_yaml_with_any(string $path): ?array {
// 1) ext-yaml?
if (function_exists('yaml_parse_file')) {
$cfg = @yaml_parse_file($path);
return is_array($cfg) ? $cfg : null;
}
// 2) Symfony/Yaml über Composer?
$autoload = __DIR__ . '/vendor/autoload.php';
if (is_file($autoload)) {
require_once $autoload;
if (class_exists(\Symfony\Component\Yaml\Yaml::class)) {
try {
$cfg = \Symfony\Component\Yaml\Yaml::parseFile($path);
return is_array($cfg) ? $cfg : null;
} catch (Throwable $e) {
return null;
}
}
}
return null;
}
function load_config(string $basePath): array {
$path = find_config_path($basePath);
if (!$path) {
http_response_code(500);
die("Config-Datei nicht gefunden. Erwartet: config.yaml|yml|json|ini in " . htmlspecialchars($basePath . '/config'));
}
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$config = null;
if (in_array($ext, ['yaml', 'yml'], true)) {
$config = parse_yaml_with_any($path);
if ($config === null) {
// Fallback: Versuche JSON/INI wenn vorhanden (z. B. User hat nur eine davon)
// Aber hier ist die Datei YAML wenn nicht parsebar, liefern wir einen klaren Fehler.
http_response_code(500);
die("YAML-Parser nicht verfügbar oder Datei ungültig: " . htmlspecialchars(basename($path)) . ". Installiere 'ext-yaml' oder 'symfony/yaml', oder nutze JSON/INI.");
}
} elseif ($ext === 'json') {
$raw = @file_get_contents($path);
if ($raw === false) {
http_response_code(500);
die("Config (JSON) konnte nicht gelesen werden: " . htmlspecialchars(basename($path)));
}
$config = json_decode($raw, true);
if (!is_array($config)) {
http_response_code(500);
die("Ungültige JSON-Config: " . htmlspecialchars(basename($path)));
}
} elseif ($ext === 'ini') {
$config = @parse_ini_file($path, true, INI_SCANNER_TYPED);
if (!is_array($config)) {
http_response_code(500);
die("Ungültige INI-Config: " . htmlspecialchars(basename($path)));
}
} else {
http_response_code(500);
die("Nicht unterstützte Config-Erweiterung: " . htmlspecialchars($ext));
}
// Normalisiere Struktur: wir erwarten auth.user / auth.pass
// Erlaube flach (user, pass) ODER verschachtelt (auth[user], auth[pass])
$user = $config['auth']['user'] ?? $config['user'] ?? null;
$pass = $config['auth']['pass'] ?? $config['pass'] ?? null;
if (!$user || !$pass) {
http_response_code(500);
die("Ungültige Config-Struktur. Erwartet z.B.:\n"
. "YAML/JSON:\n{\n \"auth\": {\"user\": \"Thomas\", \"pass\": \"Alles4Mich!\"}\n}\n"
. "INI:\n[auth]\nuser=Thomas\npass=Alles4Mich!");
}
return ['user' => (string)$user, 'pass' => (string)$pass, 'path' => $path];
}
$cfg = load_config($basePath);
$USERNAME = $cfg['user'];
$PASSWORD = $cfg['pass'];
// ===== Login / Logout Handling =====
if (isset($_GET['logout'])) {
$_SESSION = [];
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time()-42000, $params["path"], $params["domain"], $params["secure"], $params["httponly"]);
}
session_destroy();
header("Location: ".$baseUrl."/");
exit;
}
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login'])) {
$u = $_POST['user'] ?? '';
$p = $_POST['pass'] ?? '';
// hash_equals verhindert Timing-Angriffe
if (hash_equals($USERNAME, $u) && hash_equals($PASSWORD, $p)) {
session_regenerate_id(true);
$_SESSION['auth'] = true;
header("Location: ".$baseUrl."/");
exit;
} else {
$errors[] = "Login fehlgeschlagen.";
}
}
$authed = ($_SESSION['auth'] ?? false) === true;
// ===== Verzeichnisse scannen =====
$apps = [];
if ($authed) {
foreach (glob($basePath . '/*', GLOB_ONLYDIR) as $dirPath) {
$name = basename($dirPath);
// Ordner, die NICHT im Menü erscheinen
if (in_array($name, ['vendor', '.git', '.well-known', 'config'], true)) continue;
$iconRel = $name . '/icon.png';
$iconAbs = $basePath . '/' . $iconRel;
$hasIcon = is_file($iconAbs);
$apps[] = [
'name' => $name,
'url' => ($baseUrl ? $baseUrl : '') . '/' . rawurlencode($name) . '/',
'icon' => $hasIcon ? (($baseUrl ? $baseUrl : '') . '/' . $iconRel . '?v=' . filemtime($iconAbs)) : null,
];
}
usort($apps, fn($a, $b) => strcasecmp($a['name'], $b['name']));
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VIBING Experimente</title>
<style>
:root { --bg:#0f1115; --card:#171a21; --fg:#e8eef9; --muted:#9aa4b2; --accent:#7aa2f7; }
* { box-sizing: border-box; }
body {
margin: 0; font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif;
background: radial-gradient(1200px 600px at 20% -10%, #182034, transparent), var(--bg); color: var(--fg);
min-height: 100svh; display: grid; place-items: center; padding: 24px;
}
.wrap { width: min(1100px, 100%); }
header { display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; }
header h1 { margin:0; font-size: clamp(20px, 3vw, 28px); letter-spacing:.3px; }
header a.logout{ color: var(--muted); text-decoration:none; border:1px solid #2a3140; padding:8px 12px; border-radius:10px; }
.card {
background: linear-gradient(180deg, #1a1f2b, var(--card));
border: 1px solid #242b3a; border-radius: 16px; padding: clamp(16px, 3vw, 24px); box-shadow: 0 10px 30px rgba(0,0,0,.25);
}
form.login { display:grid; gap:12px; max-width:340px; }
label { font-size:14px; color: var(--muted); }
input[type=text], input[type=password] {
width:100%; padding:12px 14px; border-radius:10px; border:1px solid #2a3140; background:#0f1320; color:var(--fg);
}
button {
padding:12px 14px; border-radius:10px; border:1px solid #2e3650; background: #1f2740; color: var(--fg); cursor:pointer;
}
.error { color:#ff8b8b; margin:6px 0 0 0; font-size:14px; }
.grid { display:grid; gap:16px; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
.app { display:flex; flex-direction:column; gap:10px; padding:14px; border:1px solid #263147; border-radius:14px; background:#121726; text-decoration:none; color:inherit; }
.app:hover { border-color:#2f3c59; transform: translateY(-1px); transition: .15s ease; }
.icon {
width: 100%; aspect-ratio: 1/1; border-radius: 12px; background: #0d1220; display:grid; place-items:center;
overflow:hidden; border:1px solid #232b40;
}
.icon img { width:100%; height:100%; object-fit:cover; display:block; }
.fallback { font-size:38px; color: var(--accent); }
.name { font-weight:600; }
.empty { color: var(--muted); }
footer { margin-top:18px; color: var(--muted); font-size:13px; text-align:center; }
.hint { color: var(--muted); font-size: 12px; margin-top:8px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>VIBING Experimente</h1>
<?php if ($authed): ?>
<a class="logout" href="?logout=1">Logout</a>
<?php endif; ?>
</header>
<div class="card">
<?php if (!$authed): ?>
<form class="login" method="post" autocomplete="off">
<div>
<label for="user">Benutzername</label>
<input id="user" name="user" type="text" required>
</div>
<div>
<label for="pass">Passwort</label>
<input id="pass" name="pass" type="password" required>
</div>
<div>
<button name="login" value="1" type="submit">Login</button>
<?php foreach ($errors as $e): ?>
<div class="error"><?= htmlspecialchars($e, ENT_QUOTES, 'UTF-8') ?></div>
<?php endforeach; ?>
<div class="hint">
Unterstützte Config-Dateien: <code>config.yaml</code>, <code>config.yml</code>, <code>config.json</code>, <code>config.ini</code> im Ordner <code>config/</code>.
</div>
</div>
</form>
<?php else: ?>
<?php if (empty($apps)): ?>
<div class="empty">Noch keine Unterverzeichnisse gefunden. Lege Ordner auf Server-Ebene an (z.B. <code>/experiment1</code>) und optional ein <code>icon.png</code> hinein.</div>
<?php else: ?>
<div class="grid">
<?php foreach ($apps as $app): ?>
<a class="app" href="<?= htmlspecialchars($app['url'], ENT_QUOTES, 'UTF-8') ?>">
<div class="icon">
<?php if ($app['icon']): ?>
<img src="<?= htmlspecialchars($app['icon'], ENT_QUOTES, 'UTF-8') ?>" alt="icon">
<?php else: ?>
<div class="fallback">🧪</div>
<?php endif; ?>
</div>
<div class="name"><?= htmlspecialchars($app['name'], ENT_QUOTES, 'UTF-8') ?></div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<footer>Basis-Verzeichnis: <code><?= htmlspecialchars($baseUrl ?: '/', ENT_QUOTES, 'UTF-8') ?></code></footer>
</div>
</body>
</html>