diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..91aeebb
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,10 @@
+# Beispiel für lokale Secrets. NICHT committen: nutze .env
+DB_SERVERNAME=localhost
+DB_PORT=3306
+DB_USERNAME=FSST
+DB_PASSWORD=change-me
+DB_DATABASE=FSS_T
+
+# Optional: Basis-URL (wenn du was dynamisch bauen willst)
+APP_URL=https://fabianschieder.com
+
diff --git a/.gitignore b/.gitignore
index 5706cc7..6aa8296 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,13 @@ Thumbs.db
!icons/
!icons/*
+# Lokale Secrets
+.env
+.env.*
+
+# lokale Cache/Temp-Dateien
+*.log
+*.tmp
+
+# Adminer: echte Single-File-Download-Version optional lokal
+# adminer/adminer.php
diff --git a/adminer/README.md b/adminer/README.md
new file mode 100644
index 0000000..7343427
--- /dev/null
+++ b/adminer/README.md
@@ -0,0 +1,18 @@
+# DB-Verwaltung (Mini-Admin)
+
+Dieses Projekt enthält eine kleine, selbst implementierte DB-Verwaltung unter `/adminer`.
+
+## Setup
+1. Erstelle eine lokale `.env` im Projekt-Root (siehe `.env.example`).
+2. Trage dort deine DB-Zugangsdaten ein.
+
+> Wichtig: `.env` wird durch `.gitignore` ignoriert.
+
+## Nutzung
+- Öffne im Browser: `/adminer`
+- Login erfolgt über das Formular.
+
+## Hinweise
+- Das Tool ist bewusst minimal (Tabellenliste + Browse + einfache SQL-Query).
+- Für produktive Nutzung bitte zusätzlich absichern (z.B. Basic Auth / IP-Allowlist).
+
diff --git a/adminer/adminer.php b/adminer/adminer.php
new file mode 100644
index 0000000..e9cf603
--- /dev/null
+++ b/adminer/adminer.php
@@ -0,0 +1,19 @@
+ env_get($vars, 'DB_SERVERNAME', 'localhost') ?? 'localhost',
+ 'port' => (int)(env_get($vars, 'DB_PORT', '3306') ?? '3306'),
+ 'user' => env_get($vars, 'DB_USERNAME', '') ?? '',
+ 'pass' => env_get($vars, 'DB_PASSWORD', '') ?? '',
+ 'db' => env_get($vars, 'DB_DATABASE', '') ?? '',
+ ];
+}
+
+function admin_try_login(string $host, int $port, string $user, string $pass, string $db): array
+{
+ // Basic validation
+ if ($host === '' || $port <= 0 || $user === '' || $db === '') {
+ return ['ok' => false, 'error' => 'Bitte Host, Port, Benutzer und Datenbank angeben.'];
+ }
+
+ $dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, $port, $db);
+
+ try {
+ $pdo = new PDO($dsn, $user, $pass, [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ ]);
+
+ // smoke query
+ $pdo->query('SELECT 1');
+
+ admin_session_start();
+ $_SESSION['db_admin'] = [
+ 'ok' => true,
+ 'host' => $host,
+ 'port' => $port,
+ 'user' => $user,
+ 'pass' => $pass,
+ 'db' => $db,
+ ];
+
+ return ['ok' => true, 'error' => null];
+ } catch (Throwable $e) {
+ return ['ok' => false, 'error' => 'Login fehlgeschlagen: ' . $e->getMessage()];
+ }
+}
+
+function admin_pdo(): PDO
+{
+ admin_session_start();
+ if (empty($_SESSION['db_admin']['ok'])) {
+ throw new RuntimeException('Nicht eingeloggt');
+ }
+
+ $host = (string)$_SESSION['db_admin']['host'];
+ $port = (int)$_SESSION['db_admin']['port'];
+ $db = (string)$_SESSION['db_admin']['db'];
+ $user = (string)$_SESSION['db_admin']['user'];
+ $pass = (string)$_SESSION['db_admin']['pass'];
+
+ $dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, $port, $db);
+
+ return new PDO($dsn, $user, $pass, [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ ]);
+}
diff --git a/adminer/env.php b/adminer/env.php
new file mode 100644
index 0000000..3c3b418
--- /dev/null
+++ b/adminer/env.php
@@ -0,0 +1,49 @@
+= 2) {
+ $first = $v[0];
+ $last = $v[$len - 1];
+ if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
+ $v = substr($v, 1, -1);
+ }
+ }
+
+ if ($k !== '') $vars[$k] = $v;
+ }
+
+ return $vars;
+}
+
+function env_get(array $vars, $key, $default = null)
+{
+ if (array_key_exists($key, $vars)) return (string)$vars[$key];
+ $v = getenv((string)$key);
+ if ($v !== false) return (string)$v;
+ return $default;
+}
diff --git a/adminer/index.php b/adminer/index.php
new file mode 100644
index 0000000..7fa97b4
--- /dev/null
+++ b/adminer/index.php
@@ -0,0 +1,217 @@
+
DB-Verwaltung
Mini-Admin";
+ $body .= "";
+ $body .= "
Login-Daten werden nur in der Session gespeichert. Für Defaults wird .env aus dem Projekt-Root gelesen.
";
+
+ if ($error) $body .= '
' . h($error) . '
';
+
+ $body .= "
";
+ $body .= "
";
+
+ admin_layout('DB-Verwaltung', $body);
+ exit;
+}
+
+// Logged-in area
+try {
+ $pdo = admin_pdo();
+
+ $table = (string)($_GET['t'] ?? '');
+ $page = max(1, (int)($_GET['p'] ?? 1));
+ $limit = 50;
+ $offset = ($page - 1) * $limit;
+
+ $msg = null;
+ $queryResultHtml = '';
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'query') {
+ $sql = trim((string)($_POST['sql'] ?? ''));
+ if ($sql !== '') {
+ // Allow multiple statements? No. Keep minimal & safer.
+ if (preg_match('/;\s*\S/', $sql)) {
+ $msg = ['type' => 'err', 'text' => 'Bitte nur ein Statement ohne zusätzliche Semikolons ausführen.'];
+ } else {
+ try {
+ $stmt = $pdo->query($sql);
+ if ($stmt instanceof PDOStatement) {
+ $rows = $stmt->fetchAll();
+ $queryResultHtml .= 'Ergebnis
';
+ $queryResultHtml .= admin_render_table($rows);
+ $msg = ['type' => 'ok', 'text' => 'Query ausgeführt.'];
+ } else {
+ $msg = ['type' => 'ok', 'text' => 'Statement ausgeführt.'];
+ }
+ } catch (Throwable $e) {
+ $msg = ['type' => 'err', 'text' => 'Fehler: ' . $e->getMessage()];
+ }
+ }
+ }
+ }
+
+ // Build left nav tables
+ $tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_NUM);
+
+ $body = '';
+
+ $body .= '';
+
+ $body .= '
'
+ . '
Tabellen
'
+ . '
Klick zum Anzeigen
';
+
+ if (empty($tables)) {
+ $body .= '
Keine Tabellen gefunden.
';
+ } else {
+ $body .= '
';
+ foreach ($tables as $row) {
+ $tname = (string)$row[0];
+ $active = ($tname === $table) ? ' style="font-weight:700"' : '';
+ $body .= '- ' . h($tname) . '
';
+ }
+ $body .= '
';
+ }
+ $body .= '
';
+
+ $body .= '
';
+
+ if ($msg) {
+ $cls = $msg['type'] === 'ok' ? 'ok' : 'err';
+ $body .= '
' . h($msg['text']) . '
';
+ }
+
+ // Browse table
+ if ($table !== '') {
+ // naive identifier quoting for MySQL
+ if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) {
+ $body .= '
Ungültiger Tabellenname.
';
+ } else {
+ $body .= '
Tabelle: ' . h($table) . '
';
+ $stmt = $pdo->query('SELECT * FROM `' . $table . '` LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset);
+ $rows = $stmt->fetchAll();
+ $body .= admin_render_table($rows);
+
+ $body .= '
';
+ if ($page > 1) {
+ $body .= '
← Zurück';
+ }
+ $body .= '
Weiter →';
+ $body .= '
';
+ }
+
+ $body .= '
';
+ }
+
+ // Query box
+ $body .= '
SQL Query
';
+ $body .= '
';
+
+ $body .= $queryResultHtml;
+
+ $body .= '
'; // card
+ $body .= '
'; // grid
+
+ admin_layout('DB-Verwaltung', $body);
+} catch (Throwable $e) {
+ // Session invalid, force re-login
+ admin_logout();
+ admin_layout('DB-Verwaltung', 'DB-Verwaltung
');
+}
+
+function admin_render_table(array $rows): string
+{
+ if (empty($rows)) {
+ return '(keine Zeilen)
';
+ }
+
+ $cols = array_keys((array)$rows[0]);
+ $html = '';
+ foreach ($cols as $c) {
+ $html .= '| ' . h((string)$c) . ' | ';
+ }
+ $html .= '
';
+
+ foreach ($rows as $r) {
+ $html .= '';
+ foreach ($cols as $c) {
+ $v = $r[$c] ?? null;
+ if ($v === null) {
+ $cell = 'NULL';
+ } else {
+ $s = (string)$v;
+ $cell = strlen($s) > 500 ? h(substr($s, 0, 500)) . '…' : h($s);
+ }
+ $html .= '| ' . $cell . ' | ';
+ }
+ $html .= '
';
+ }
+
+ $html .= '
';
+ return $html;
+}
+
diff --git a/adminer/views.php b/adminer/views.php
new file mode 100644
index 0000000..bb2687d
--- /dev/null
+++ b/adminer/views.php
@@ -0,0 +1,41 @@
+\n";
+ echo "\n\n";
+ echo "\n";
+ echo "\n";
+ echo '' . h($title) . "\n";
+ echo "\n";
+ echo "\n\n";
+ echo "\n";
+ echo $bodyHtml;
+ echo "
\n\n";
+}
diff --git a/assets/js/main.js b/assets/js/main.js
new file mode 100644
index 0000000..8d8e886
--- /dev/null
+++ b/assets/js/main.js
@@ -0,0 +1,96 @@
+// ── Typewriter ───────────────────────────────────────────
+(function () {
+ const el = document.getElementById('typewriter');
+ const words = ['Entwickler', 'Schüler', 'Macher', 'Selfhoster', 'Tüftler'];
+ let wi = 0, ci = 0, deleting = false;
+
+ function type() {
+ if (!el) return;
+ const word = words[wi];
+ el.textContent = deleting ? word.slice(0, ci--) : word.slice(0, ci++);
+
+ let delay = deleting ? 60 : 110;
+ if (!deleting && ci > word.length) { delay = 1400; deleting = true; }
+ if (deleting && ci < 0) { deleting = false; wi = (wi + 1) % words.length; ci = 0; delay = 300; }
+ setTimeout(type, delay);
+ }
+ setTimeout(type, 900);
+})();
+
+// ── Particles ────────────────────────────────────────────
+(function () {
+ const canvas = document.getElementById('particle-canvas');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ const COLORS = ['#6366f1', '#0ea5e9', '#a855f7', '#ec4899', '#22d3ee'];
+ let W, H, particles = [];
+
+ function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; }
+ function rand(a, b) { return Math.random() * (b - a) + a; }
+
+ function mkP() {
+ return { x: rand(0,W), y: rand(0,H), r: rand(0.8,2.2), dx: rand(-0.25,0.25), dy: rand(-0.35,-0.08),
+ alpha: rand(0.2,0.8), fade: rand(0.002,0.006), color: COLORS[Math.floor(Math.random()*COLORS.length)] };
+ }
+
+ function init() { resize(); particles = Array.from({length:90}, mkP); }
+
+ function draw() {
+ ctx.clearRect(0, 0, W, H);
+ for (let p of particles) {
+ ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI*2);
+ ctx.fillStyle = p.color; ctx.globalAlpha = p.alpha; ctx.fill();
+ p.x += p.dx; p.y += p.dy; p.alpha -= p.fade;
+ if (p.alpha <= 0 || p.y < -5) Object.assign(p, mkP(), { y: H+5, alpha: rand(0.2,0.7) });
+ }
+ ctx.globalAlpha = 1;
+ requestAnimationFrame(draw);
+ }
+
+ window.addEventListener('resize', resize);
+ init(); draw();
+})();
+
+// ── 3D Tilt Cards ────────────────────────────────────────
+document.querySelectorAll('.card').forEach(card => {
+ card.addEventListener('mousemove', function (e) {
+ const rect = this.getBoundingClientRect();
+ const cx = rect.left + rect.width / 2;
+ const cy = rect.top + rect.height / 2;
+ const dx = (e.clientX - cx) / (rect.width / 2);
+ const dy = (e.clientY - cy) / (rect.height / 2);
+ const rotX = (-dy * 8).toFixed(2);
+ const rotY = ( dx * 8).toFixed(2);
+ this.style.transform = `perspective(600px) rotateX(${rotX}deg) rotateY(${rotY}deg) translateY(-3px) scale(1.02)`;
+
+ // glare
+ let glare = this.querySelector('.card-glare');
+ if (!glare) {
+ glare = document.createElement('div');
+ glare.className = 'card-glare';
+ this.appendChild(glare);
+ }
+ const gx = ((e.clientX - rect.left) / rect.width * 100).toFixed(1);
+ const gy = ((e.clientY - rect.top) / rect.height * 100).toFixed(1);
+ glare.style.background = `radial-gradient(circle at ${gx}% ${gy}%, rgba(255,255,255,0.12) 0%, transparent 65%)`;
+ });
+
+ card.addEventListener('mouseleave', function () {
+ this.style.transform = '';
+ this.querySelector('.card-glare')?.remove();
+ });
+
+ // Ripple
+ card.addEventListener('click', function (e) {
+ const circle = document.createElement('span');
+ const diameter = Math.max(this.clientWidth, this.clientHeight);
+ const rect = this.getBoundingClientRect();
+ circle.classList.add('ripple');
+ circle.style.width = circle.style.height = diameter + 'px';
+ circle.style.left = (e.clientX - rect.left - diameter / 2) + 'px';
+ circle.style.top = (e.clientY - rect.top - diameter / 2) + 'px';
+ this.querySelector('.ripple')?.remove();
+ this.appendChild(circle);
+ });
+});
+
diff --git a/includes/config/projects.php b/includes/config/projects.php
new file mode 100644
index 0000000..4cf88eb
--- /dev/null
+++ b/includes/config/projects.php
@@ -0,0 +1,77 @@
+ [
+ [
+ 'title' => 'Gitea',
+ 'description' => 'Mein privater Gitea - Server',
+ 'url' => '/git',
+ 'logo' => '/icons/gitea.svg',
+ 'color' => '#609926',
+ 'external' => true,
+ ],
+ [
+ 'title' => 'Nextcloud',
+ 'description' => 'Meine persönliche Nextcloud',
+ 'url' => '/nextcloud',
+ 'logo' => '/icons/nextcloud.svg',
+ 'color' => '#0082c9',
+ 'external' => true,
+ ],
+ [
+ 'title' => 'Server Dashboard',
+ 'description' => 'Server-Verwaltung mit Cockpit',
+ 'url' => 'https://cockpit.fabianschieder.com',
+ 'logo' => '/icons/ubuntu.svg',
+ 'color' => '#E95420',
+ 'external' => true,
+ ],
+ ],
+ 'schule' => [
+ [
+ 'title' => 'Geizkragen',
+ 'description' => 'Ein Online - Preisvergleichsportal',
+ 'url' => 'https://geizkragen.store',
+ 'logo' => '/icons/geizkragen.png',
+ 'color' => '#0082c9',
+ 'external' => true,
+ ],
+ ],
+ 'dienste' => [
+ [
+ 'title' => 'Home Assistant',
+ 'description' => 'Mein privater HomeAssistant Server',
+ 'url' => 'http://homeassistant.fabianschieder.com',
+ 'logo' => '/icons/homeassistant.svg',
+ 'color' => '#18BCF2',
+ 'external' => true,
+ ],
+ [
+ 'title' => 'NAS',
+ 'description' => 'Mein privater Netzwerkspeicher',
+ 'url' => 'http://nas.fabianschieder.com',
+ 'logo' => '/icons/nas.svg',
+ 'color' => '#a855f7',
+ 'external' => true,
+ ],
+ ],
+
+ // Neue Kategorie: Admin/Tools
+ 'admin' => [
+ [
+ 'title' => 'Datenbankverwaltung',
+ 'description' => 'Login erfolgt im Tool (keine Credentials im Code).',
+ 'url' => '/adminer',
+ 'logo' => null,
+ 'color' => '#14b8a6',
+ 'external' => false,
+ ],
+ ],
+];
+
diff --git a/includes/config/server_status_targets.php b/includes/config/server_status_targets.php
new file mode 100644
index 0000000..1f24991
--- /dev/null
+++ b/includes/config/server_status_targets.php
@@ -0,0 +1,35 @@
+ 'Gitea',
+ 'url' => 'https://fabianschieder.com/git',
+ ],
+ [
+ 'title' => 'Nextcloud',
+ 'url' => 'https://fabianschieder.com/nextcloud',
+ ],
+ [
+ 'title' => 'Home Assistant',
+ 'url' => 'http://homeassistant.fabianschieder.com',
+ ],
+ [
+ 'title' => 'NAS',
+ 'url' => 'http://nas.fabianschieder.com',
+ ],
+ [
+ 'title' => 'Cockpit',
+ 'url' => 'https://cockpit.fabianschieder.com',
+ ],
+ [
+ 'title' => 'Geizkragen',
+ 'url' => 'https://geizkragen.store',
+ ],
+];
+
diff --git a/includes/lib/server_status.php b/includes/lib/server_status.php
new file mode 100644
index 0000000..d9cfe65
--- /dev/null
+++ b/includes/lib/server_status.php
@@ -0,0 +1,175 @@
+ [
+ 'method' => 'HEAD',
+ 'timeout' => $timeoutSeconds,
+ 'ignore_errors' => true,
+ 'follow_location' => 0,
+ 'max_redirects' => 0,
+ 'header' => "User-Agent: ServerStatus/1.0\r\n",
+ ],
+ 'ssl' => [
+ 'verify_peer' => true,
+ 'verify_peer_name' => true,
+ ],
+ ]);
+
+ $code = null;
+ $error = null;
+
+ set_error_handler(static function (int $severity, string $message) use (&$error): bool {
+ $error = $message;
+ return true;
+ });
+ $fp = @fopen($url, 'rb', false, $ctx);
+ restore_error_handler();
+
+ if ($fp !== false) {
+ fclose($fp);
+ }
+
+ $ms = (int)round((microtime(true) - $start) * 1000);
+
+ // Parse status code from $http_response_header
+ if (isset($http_response_header) && is_array($http_response_header)) {
+ foreach ($http_response_header as $h) {
+ if (preg_match('#^HTTP/\S+\s+(\d{3})#i', (string)$h, $m)) {
+ $code = (int)$m[1];
+ break;
+ }
+ }
+ }
+
+ $ok = ($code !== null) && ($code >= 200 && $code < 400);
+ if ($code === null && $error === null) {
+ $error = 'Unbekannter Fehler';
+ }
+
+ return ['ok' => $ok, 'code' => $code, 'ms' => $ms, 'error' => $error];
+}
+
+function build_server_status(array $targets): array
+{
+ $allowedHosts = [];
+ foreach ($targets as $t) {
+ if (!empty($t['url'])) {
+ $p = parse_url((string)$t['url']);
+ if (is_array($p) && !empty($p['host'])) {
+ $allowedHosts[] = strtolower((string)$p['host']);
+ }
+ }
+ }
+ $allowedHosts = array_values(array_unique($allowedHosts));
+
+ $results = [];
+ foreach ($targets as $t) {
+ $url = (string)($t['url'] ?? '');
+ if ($url === '' || !is_allowed_http_url($url, $allowedHosts)) {
+ $results[] = ['url' => $url, 'state' => 'unknown', 'ms' => null, 'detail' => 'Nicht erlaubt'];
+ continue;
+ }
+
+ $r = check_http_head($url);
+ $results[] = [
+ 'url' => $url,
+ 'state' => $r['ok'] ? 'up' : 'down',
+ 'ms' => $r['ms'],
+ 'detail' => $r['ok'] ? null : ($r['error'] ?? null),
+ ];
+ }
+
+ return $results;
+}
+
diff --git a/includes/lib/view_helpers.php b/includes/lib/view_helpers.php
new file mode 100644
index 0000000..d9494dc
--- /dev/null
+++ b/includes/lib/view_helpers.php
@@ -0,0 +1,14 @@
+ '🔒 Privat',
+ 'schule' => '🎓 Schule',
+ 'dienste' => '🔗 Verlinkungen',
+ 'admin' => '🛠️ Admin/Tools',
+ default => $category,
+ };
+}
+
diff --git a/includes/views/footer.php b/includes/views/footer.php
new file mode 100644
index 0000000..5743417
--- /dev/null
+++ b/includes/views/footer.php
@@ -0,0 +1,4 @@
+
+
diff --git a/includes/views/header.php b/includes/views/header.php
new file mode 100644
index 0000000..4037e00
--- /dev/null
+++ b/includes/views/header.php
@@ -0,0 +1,6 @@
+
+ FS
+ Fabian Schieder
+
+
+
diff --git a/includes/views/layout_foot.php b/includes/views/layout_foot.php
new file mode 100644
index 0000000..81d7cfb
--- /dev/null
+++ b/includes/views/layout_foot.php
@@ -0,0 +1,5 @@
+
+
+