Revamp Adminer UI with new CSS styles and enhanced HTML structure for improved user experience

This commit is contained in:
Fabian Schieder 2026-02-28 20:17:19 +01:00
parent b74752b2fd
commit 156a0ddeb3
3 changed files with 584 additions and 331 deletions

View File

@ -1,113 +1,353 @@
/* Adminer Mini-Admin UI angelehnt an style.css (Glassmorphism + Accent) */ /* ===== ADMINER gleicher Look wie style.css ===== */
:root{
--bg:#0b1020; /* ── RESET & BASE ─────────────────────────────── */
--text:#e5e7eb; *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
--muted:#94a3b8;
--card:rgba(255,255,255,.06); :root {
--cardBorder:rgba(255,255,255,.10); --bg: #0f0f13;
--accent:#6366f1; --surface: #1a1a24;
--accent2:#22d3ee; --surface-hover: #22222f;
--danger:#ef4444; --border: rgba(255,255,255,0.07);
--success:#22c55e; --text: #e8e8f0;
--text-muted: #8888a8;
--radius: 16px;
--transition: 0.25s ease;
--accent: #6366f1;
} }
*{box-sizing:border-box} html { scroll-behavior: smooth; overflow-x: hidden; }
html,body{height:100%}
body{ body {
margin:0; background-color: var(--bg);
font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif; color: var(--text);
background:radial-gradient(1200px 600px at 20% 10%, rgba(99,102,241,.25), transparent 60%), font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
radial-gradient(900px 600px at 80% 30%, rgba(34,211,238,.18), transparent 60%), min-height: 100vh;
radial-gradient(900px 700px at 40% 90%, rgba(168,85,247,.14), transparent 60%), display: flex;
var(--bg); flex-direction: column;
color:var(--text); align-items: center;
padding: 2rem 1rem 4rem;
position: relative;
overflow-x: hidden;
overflow-y: auto;
} }
.wrap{max-width:1100px;margin:34px auto;padding:0 16px} /* ── BACKGROUND ───────────────────────────────── */
@keyframes bgShift {
.top{ 0% { opacity:1; transform:scale(1) translate(0,0); }
display:flex; 33% { opacity:.85; transform:scale(1.15) translate(2%,3%); }
align-items:center; 66% { opacity:.9; transform:scale(1.08) translate(-3%,-2%); }
justify-content:space-between; 100% { opacity:1; transform:scale(1) translate(0,0); }
gap:12px;
margin-bottom:16px;
} }
h1{font-size:28px;letter-spacing:.2px;margin:0} .background-blur {
position:fixed; inset:-25%; z-index:-1; pointer-events:none;
a{color:#93c5fd;text-decoration:none} background:
a:hover{text-decoration:underline} radial-gradient(ellipse 60% 40% at 20% 10%, rgba(99,102,241,.18), transparent),
radial-gradient(ellipse 50% 40% at 80% 80%, rgba(14,165,233,.13), transparent),
.pill{ radial-gradient(ellipse 40% 30% at 60% 40%, rgba(168,85,247,.08), transparent);
font-size:12px; animation: bgShift 12s ease-in-out infinite;
background:rgba(255,255,255,.10);
border:1px solid rgba(255,255,255,.14);
padding:5px 10px;
border-radius:999px;
} }
.card{ #particle-canvas {
background:var(--card); position:fixed; inset:0; z-index:-1; pointer-events:none; opacity:.55;
border:1px solid var(--cardBorder);
border-radius:16px;
padding:16px;
backdrop-filter: blur(14px);
box-shadow:0 18px 40px rgba(0,0,0,.35);
} }
.grid{display:grid;grid-template-columns:320px 1fr;gap:16px} /* ── ORBS ─────────────────────────────────────── */
@media (max-width: 900px){.grid{grid-template-columns:1fr}} .orb {
position:fixed; border-radius:50%; pointer-events:none; z-index:-1;
filter:blur(70px); opacity:0;
animation:orbFloat var(--dur,18s) ease-in-out infinite var(--delay,0s);
}
.orb-1 { width:320px;height:320px; background:radial-gradient(circle,rgba(99,102,241,.25),transparent 70%); top:-80px;left:-80px; --dur:20s;--delay:0s; }
.orb-2 { width:260px;height:260px; background:radial-gradient(circle,rgba(14,165,233,.2), transparent 70%); bottom:5%;right:-60px; --dur:17s;--delay:-6s; }
.orb-3 { width:200px;height:200px; background:radial-gradient(circle,rgba(168,85,247,.18),transparent 70%); top:45%;left:-40px; --dur:23s;--delay:-11s;}
.orb-4 { width:180px;height:180px; background:radial-gradient(circle,rgba(236,72,153,.15), transparent 70%); top:20%;right:-30px; --dur:19s;--delay:-4s; }
@keyframes orbFloat {
0% {opacity:0; transform:translate(0,0) scale(1); }
10% {opacity:1;}
50% {opacity:1; transform:translate(20px,25px) scale(1.08); }
90% {opacity:1;}
100%{opacity:0; transform:translate(0,0) scale(1); }
}
.muted{color:var(--muted)} /* Grid lines */
body::before {
content:''; position:fixed; inset:0; z-index:-1; pointer-events:none;
background-image:
linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
background-size:60px 60px;
mask-image:radial-gradient(ellipse 80% 80% at 50% 50%, black 30%, transparent 100%);
-webkit-mask-image:radial-gradient(ellipse 80% 80% at 50% 50%, black 30%, transparent 100%);
}
label{display:block;color:#cbd5e1;font-weight:600} /* ── LAYOUT ───────────────────────────────────── */
.wrap {
width: 100%;
max-width: 900px;
}
input,textarea,select{ /* ── ANIMATIONS ───────────────────────────────── */
width:100%; @keyframes fadeDown {
padding:10px 12px; from { opacity:0; transform:translateY(-20px); }
margin-top:6px; to { opacity:1; transform:translateY(0); }
}
@keyframes fadeUp {
from { opacity:0; transform:translateY(24px); }
to { opacity:1; transform:translateY(0); }
}
@keyframes shimmerText {
from { background-position:0% center; }
to { background-position:200% center; }
}
/* ── HEADER ───────────────────────────────────── */
.admin-header {
text-align:center;
margin-bottom: 2.5rem;
margin-top: 1.5rem;
animation: fadeDown .7s ease both;
}
@keyframes avatarPulse {
0%,100% { box-shadow:0 0 0 4px rgba(99,102,241,.2),0 0 20px rgba(99,102,241,.1); }
50% { box-shadow:0 0 0 8px rgba(99,102,241,.12),0 0 40px rgba(99,102,241,.2); }
}
.admin-avatar {
width:72px; height:72px; border-radius:50%;
background:linear-gradient(135deg,#6366f1,#0ea5e9);
display:flex; align-items:center; justify-content:center;
font-size:1.6rem; font-weight:700;
margin:0 auto 1rem;
animation:avatarPulse 3s ease-in-out infinite;
transition:transform .3s ease;
cursor:default; user-select:none;
}
.admin-avatar:hover { transform:scale(1.08) rotate(6deg); }
.admin-title {
font-size: clamp(1.5rem,3.5vw,2.2rem);
font-weight:700;
background:linear-gradient(90deg,#e8e8f0 0%,#6366f1 40%,#0ea5e9 60%,#e8e8f0 100%);
background-size:200% auto;
-webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text;
letter-spacing:-.5px;
animation:shimmerText 5s linear infinite;
}
.admin-subtitle {
color:var(--text-muted);
margin-top:.4rem;
font-size:.95rem;
animation: fadeDown .9s ease .3s both;
}
/* ── PILL / TAG ───────────────────────────────── */
.pill {
display:inline-block;
font-size:.72rem; font-weight:600;
text-transform:uppercase; letter-spacing:1px;
color:var(--text-muted);
background:rgba(255,255,255,.06);
border:1px solid var(--border);
padding:3px 10px; border-radius:999px;
vertical-align:middle; margin-left:8px;
}
/* ── CARD ─────────────────────────────────────── */
.card {
background:var(--surface);
border:1px solid var(--border);
border-radius:var(--radius);
padding:1.4rem 1.6rem;
position:relative;
overflow:hidden;
animation: fadeUp .5s ease both;
}
.card::before {
content:''; position:absolute; inset:0; border-radius:var(--radius);
opacity:0; transition:opacity var(--transition);
background:linear-gradient(135deg,rgba(99,102,241,.07),transparent 60%);
pointer-events:none;
}
.card:hover::before { opacity:1; }
/* ── GRID LAYOUT ──────────────────────────────── */
.admin-grid {
display:grid;
grid-template-columns:240px 1fr;
gap:16px;
align-items:start;
}
@media(max-width:700px){ .admin-grid{ grid-template-columns:1fr; } }
/* ── TYPOGRAPHY ───────────────────────────────── */
h2 {
font-size:1rem; font-weight:700;
color:var(--text-muted);
text-transform:uppercase; letter-spacing:1.2px;
margin-bottom:.75rem;
padding-bottom:.5rem;
border-bottom:1px solid var(--border);
}
h3 {
font-size:.95rem; font-weight:600; color:var(--text-muted);
text-transform:uppercase; letter-spacing:1px;
margin-bottom:.6rem;
}
a { color:#93c5fd; text-decoration:none; }
a:hover { text-decoration:underline; }
.muted { color:var(--text-muted); font-size:.88rem; }
code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:.85em;
background:rgba(255,255,255,.06); border:1px solid var(--border); padding:1px 5px; border-radius:5px; }
hr { border:0; border-top:1px solid var(--border); margin:1.2rem 0; }
/* ── FORM ─────────────────────────────────────── */
label {
display:block;
font-size:.82rem; font-weight:600; color:var(--text-muted);
text-transform:uppercase; letter-spacing:.8px;
margin-bottom:.35rem;
}
input, textarea, select {
width:100%; padding:10px 14px;
border-radius:12px; border-radius:12px;
background:rgba(0,0,0,.35); background:rgba(255,255,255,.04);
border:1px solid rgba(255,255,255,.14); border:1px solid var(--border);
color:var(--text); color:var(--text);
font-family:inherit; font-size:.95rem;
outline:none; outline:none;
transition:border-color var(--transition), box-shadow var(--transition);
} }
input:focus,textarea:focus,select:focus{border-color:rgba(99,102,241,.75);box-shadow:0 0 0 3px rgba(99,102,241,.20)} input:focus, textarea:focus, select:focus {
border-color:rgba(99,102,241,.7);
box-shadow:0 0 0 3px rgba(99,102,241,.15);
}
textarea { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; resize:vertical; }
hr{border:0;border-top:1px solid rgba(255,255,255,.10);margin:16px 0} .field { margin-bottom:1rem; }
.btn{ /* ── BUTTONS ──────────────────────────────────── */
display:inline-flex; .btn {
align-items:center; display:inline-flex; align-items:center; justify-content:center; gap:6px;
justify-content:center; padding:10px 18px; border-radius:var(--radius);
gap:8px; border:1px solid rgba(255,255,255,.12);
padding:10px 12px; cursor:pointer; font-size:.9rem; font-weight:600; letter-spacing:.2px;
border-radius:12px; background:linear-gradient(135deg,rgba(99,102,241,.9),rgba(14,165,233,.65));
border:1px solid rgba(255,255,255,.16); color:#fff;
cursor:pointer;
background:linear-gradient(135deg, rgba(99,102,241,.95), rgba(34,211,238,.55));
color:white;
text-decoration:none; text-decoration:none;
transition:transform .12s ease, filter .12s ease; transition:transform var(--transition), filter var(--transition), box-shadow var(--transition);
box-shadow:0 4px 18px rgba(99,102,241,.25);
white-space:nowrap;
} }
.btn:hover{filter:brightness(1.06)} .btn:hover { filter:brightness(1.1); transform:translateY(-1px); text-decoration:none; }
.btn:active{transform:translateY(1px)} .btn:active { transform:translateY(1px); }
.btn.secondary{
background:rgba(255,255,255,.08); .btn-sm { padding:7px 12px; font-size:.82rem; border-radius:10px; }
.btn-ghost {
background:rgba(255,255,255,.06);
border:1px solid var(--border);
box-shadow:none;
}
.btn-ghost:hover { background:rgba(255,255,255,.1); filter:none; }
/* ── TABS ─────────────────────────────────────── */
.tabs { display:flex; gap:6px; margin-bottom:1.4rem; }
.tab {
padding:8px 18px; border-radius:10px; font-size:.88rem; font-weight:600;
border:1px solid var(--border);
background:rgba(255,255,255,.04); color:var(--text-muted);
text-decoration:none; transition:background var(--transition), color var(--transition), border-color var(--transition);
cursor:pointer;
}
.tab:hover { background:rgba(255,255,255,.08); color:var(--text); text-decoration:none; }
.tab.active {
background:rgba(99,102,241,.18); border-color:rgba(99,102,241,.4);
color:#a5b4fc;
} }
.notice{padding:10px 12px;border-radius:12px;border:1px solid rgba(255,255,255,.14)} /* ── NOTICE / ALERTS ──────────────────────────── */
.notice.ok{background:rgba(34,197,94,.12);border-color:rgba(34,197,94,.25);color:#bbf7d0} .notice {
.notice.err{background:rgba(239,68,68,.10);border-color:rgba(239,68,68,.25);color:#fecaca} padding:10px 14px; border-radius:12px; font-size:.88rem;
border:1px solid var(--border); margin-bottom:1rem;
}
.notice-ok { background:rgba(34,197,94,.1); border-color:rgba(34,197,94,.25); color:#86efac; }
.notice-err { background:rgba(239,68,68,.09); border-color:rgba(239,68,68,.25); color:#fca5a5; }
.table{width:100%;border-collapse:collapse;overflow:hidden;border-radius:14px} /* ── NAV LIST (Tabellenliste) ─────────────────── */
.table th,.table td{padding:9px 10px;border-bottom:1px solid rgba(255,255,255,.10);vertical-align:top} .nav-list { list-style:none; }
.table th{color:#cbd5e1;text-align:left;font-weight:700;background:rgba(255,255,255,.04)} .nav-list li { margin:3px 0; }
.code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,monospace} .nav-list a {
display:block; padding:8px 12px; border-radius:12px;
color:var(--text-muted); font-size:.88rem;
border:1px solid transparent;
transition:background var(--transition), color var(--transition), border-color var(--transition);
}
.nav-list a:hover { background:var(--surface-hover); color:var(--text); text-decoration:none; }
.nav-list a.active {
background:rgba(99,102,241,.12);
border-color:rgba(99,102,241,.25);
color:#a5b4fc;
}
.list{list-style:none;margin:0;padding:0} /* ── TABLE ────────────────────────────────────── */
.list li{margin:6px 0} .db-table {
.list a{display:block;padding:8px 10px;border-radius:12px} width:100%; border-collapse:collapse; font-size:.85rem;
.list a:hover{background:rgba(255,255,255,.06)} overflow:hidden;
.list a.active{background:rgba(99,102,241,.16);border:1px solid rgba(99,102,241,.25)} }
.db-table thead th {
padding:9px 10px; text-align:left;
font-size:.75rem; font-weight:700; text-transform:uppercase; letter-spacing:.8px;
color:var(--text-muted);
background:rgba(255,255,255,.03);
border-bottom:1px solid var(--border);
}
.db-table tbody td {
padding:9px 10px;
border-bottom:1px solid var(--border);
vertical-align:top;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:.82rem;
color:var(--text);
word-break:break-word;
max-width:280px;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.db-table tbody tr:hover td { background:rgba(255,255,255,.025); }
.db-table tbody tr:last-child td { border-bottom:none; }
.null-val { color:var(--text-muted); font-style:italic; font-size:.8rem; }
/* ── PAGINATION ───────────────────────────────── */
.pagination { display:flex; gap:8px; margin-top:12px; align-items:center; }
/* ── TOP BAR ──────────────────────────────────── */
.top-bar {
display:flex; align-items:center; justify-content:space-between; gap:12px;
margin-bottom:1.4rem;
flex-wrap:wrap;
}
.top-bar-left { display:flex; align-items:center; gap:10px; }
.top-bar-actions { display:flex; gap:8px; flex-wrap:wrap; }
/* ── LOGIN CARD CENTERED ──────────────────────── */
.login-wrap {
width:100%; max-width:420px;
margin:0 auto;
animation: fadeUp .5s ease both;
}
/* ── BACK LINK ────────────────────────────────── */
.back-link {
display:inline-flex; align-items:center; gap:5px;
color:var(--text-muted); font-size:.85rem;
margin-bottom:1.2rem;
}
.back-link:hover { color:var(--text); text-decoration:none; }
@media(max-width:480px) {
body { padding:1.2rem .75rem 3rem; }
.admin-grid { grid-template-columns:1fr; }
}

View File

@ -10,24 +10,25 @@ adminer_app_session_start();
try { try {
adminer_app_bootstrap(); adminer_app_bootstrap();
} catch (Throwable $e) { } catch (Throwable $e) {
admin_layout('DB-Verwaltung', '<div class="top"><h1>DB-Verwaltung</h1></div><div class="card"><div class="err">' . h($e->getMessage()) . '</div></div>'); admin_layout('DB-Verwaltung', '<div class="notice notice-err">' . h($e->getMessage()) . '</div>', 'Fehler beim Start');
exit; exit;
} }
$appPage = (string)($_GET['page'] ?? 'login'); // login|register // App-Logout
$appAction = (string)($_GET['auth'] ?? ''); if ((string)($_GET['auth'] ?? '') === 'logout') {
if ($appAction === 'logout') {
adminer_app_logout(); adminer_app_logout();
header('Location: /adminer', true, 302); header('Location: /adminer', true, 302);
exit; exit;
} }
// Handle app login $appPage = (string)($_GET['page'] ?? 'login');
$appError = null; $appError = null;
$appRegError = null;
$appRegOk = null;
// Login POST
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'app_login') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'app_login') {
$u = (string)($_POST['username'] ?? ''); $res = adminer_app_try_login((string)($_POST['username'] ?? ''), (string)($_POST['password'] ?? ''));
$p = (string)($_POST['password'] ?? '');
$res = adminer_app_try_login($u, $p);
if (!empty($res['ok'])) { if (!empty($res['ok'])) {
header('Location: /adminer', true, 302); header('Location: /adminer', true, 302);
exit; exit;
@ -36,23 +37,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') ==
$appPage = 'login'; $appPage = 'login';
} }
// Handle registration // Register POST
$appRegError = null;
$appRegOk = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'app_register') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'app_register') {
$u = (string)($_POST['username'] ?? ''); $res = adminer_app_try_register(
$p1 = (string)($_POST['password'] ?? ''); (string)($_POST['username'] ?? ''),
$p2 = (string)($_POST['password2'] ?? ''); (string)($_POST['password'] ?? ''),
(string)($_POST['password2'] ?? '')
$res = adminer_app_try_register($u, $p1, $p2); );
if (!empty($res['ok'])) { if (!empty($res['ok'])) {
// Auto-login after register $lr = adminer_app_try_login((string)($_POST['username'] ?? ''), (string)($_POST['password'] ?? ''));
$loginRes = adminer_app_try_login($u, $p1); if (!empty($lr['ok'])) {
if (!empty($loginRes['ok'])) {
header('Location: /adminer', true, 302); header('Location: /adminer', true, 302);
exit; exit;
} }
$appRegOk = 'Registrierung erfolgreich. Bitte einloggen.'; $appRegOk = 'Konto erstellt! Bitte einloggen.';
$appPage = 'login'; $appPage = 'login';
} else { } else {
$appRegError = (string)($res['error'] ?? 'Registrierung fehlgeschlagen.'); $appRegError = (string)($res['error'] ?? 'Registrierung fehlgeschlagen.');
@ -60,81 +58,69 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') ==
} }
} }
// ── LOGIN / REGISTER SEITE ────────────────────────────────────────────────
if (!adminer_app_is_logged_in()) { if (!adminer_app_is_logged_in()) {
$canRegister = adminer_app_allow_register(); $canReg = adminer_app_allow_register();
$isReg = ($appPage === 'register');
$body = '<div class="top"><h1>DB-Verwaltung</h1><span class="pill">Login</span></div>'; $body = '<div class="login-wrap">';
$body .= '<div class="card">';
// Tabs // Tabs
$body .= '<div style="display:flex;gap:10px;margin-bottom:14px">' $body .= '<div class="tabs">';
. '<a class="btn secondary" href="/adminer?page=login" style="text-decoration:none">Login</a>' $body .= '<a class="tab ' . (!$isReg ? 'active' : '') . '" href="/adminer?page=login">Login</a>';
. ($canRegister ? '<a class="btn secondary" href="/adminer?page=register" style="text-decoration:none">Registrieren</a>' : '') if ($canReg) $body .= '<a class="tab ' . ($isReg ? 'active' : '') . '" href="/adminer?page=register">Registrieren</a>';
. '</div>'; $body .= '</div>';
if ($appRegOk) { $body .= '<div class="card">';
$body .= '<div class="notice ok">' . h($appRegOk) . '</div><br>';
}
if ($appPage === 'register') { if ($appRegOk) $body .= '<div class="notice notice-ok">' . h($appRegOk) . '</div>';
if (!$canRegister) { if ($appError) $body .= '<div class="notice notice-err">' . h($appError) . '</div>';
$body .= '<div class="notice err">Registrierung ist deaktiviert.</div>'; if ($appRegError)$body .= '<div class="notice notice-err">' . h($appRegError) . '</div>';
} else {
if ($appRegError) $body .= '<div class="notice err">' . h($appRegError) . '</div><br>';
$body .= '<h3>Registrieren</h3>'; if ($isReg && $canReg) {
// ── Registrierungsformular ──────────────────────────────────────
$body .= '<form method="post">' $body .= '<form method="post">'
. '<input type="hidden" name="action" value="app_register">' . '<input type="hidden" name="action" value="app_register">'
. '<label>Benutzername<br><input name="username" autocomplete="username"></label><br><br>' . '<div class="field"><label>Benutzername</label><input name="username" autocomplete="username" placeholder="z.B. fabian"></div>'
. '<label>Passwort<br><input name="password" type="password" autocomplete="new-password"></label><br><br>' . '<div class="field"><label>Passwort</label><input name="password" type="password" autocomplete="new-password" placeholder="mind. 8 Zeichen"></div>'
. '<label>Passwort wiederholen<br><input name="password2" type="password" autocomplete="new-password"></label><br><br>' . '<div class="field"><label>Passwort wiederholen</label><input name="password2" type="password" autocomplete="new-password" placeholder="Wiederholung"></div>'
. '<button class="btn" type="submit">Account erstellen</button>' . '<button class="btn" type="submit" style="width:100%">Konto erstellen</button>'
. '</form>'; . '</form>';
$body .= '<p class="muted" style="margin-top:.9rem;text-align:center">Bereits ein Konto? <a href="/adminer?page=login">Login</a></p>';
$body .= '<div class="muted" style="margin-top:12px">Dein Account wird in <code class="code">FabianWebsite.adminer_users</code> gespeichert.</div>';
}
} else { } else {
if ($appError) $body .= '<div class="notice err">' . h($appError) . '</div><br>'; // ── Login-Formular ──────────────────────────────────────────────
$body .= '<h3>Login</h3>';
$body .= '<form method="post">' $body .= '<form method="post">'
. '<input type="hidden" name="action" value="app_login">' . '<input type="hidden" name="action" value="app_login">'
. '<label>Benutzername<br><input name="username" autocomplete="username"></label><br><br>' . '<div class="field"><label>Benutzername</label><input name="username" autocomplete="username" placeholder="Dein Benutzername"></div>'
. '<label>Passwort<br><input name="password" type="password" autocomplete="current-password"></label><br><br>' . '<div class="field"><label>Passwort</label><input name="password" type="password" autocomplete="current-password" placeholder="Dein Passwort"></div>'
. '<button class="btn" type="submit">Anmelden</button>' . '<button class="btn" type="submit" style="width:100%">Anmelden</button>'
. '</form>'; . '</form>';
if ($canReg) $body .= '<p class="muted" style="margin-top:.9rem;text-align:center">Noch kein Konto? <a href="/adminer?page=register">Registrieren</a></p>';
if ($canRegister) {
$body .= '<div class="muted" style="margin-top:12px">Noch kein Account? <a href="/adminer?page=register">Registrieren</a></div>';
}
} }
$body .= '</div>';
$body .= '</div>'; $body .= '</div>';
admin_layout('DB-Verwaltung', $body); admin_layout('DB-Verwaltung', $body, $isReg ? 'Neues Konto erstellen' : 'Bitte einloggen');
exit; exit;
} }
// ── DB-VERBINDUNGS-LOGIN ──────────────────────────────────────────────────
require_once __DIR__ . '/auth.php'; require_once __DIR__ . '/auth.php';
admin_session_start(); admin_session_start();
$action = (string)($_GET['a'] ?? ''); if ((string)($_GET['a'] ?? '') === 'logout') {
if ($action === 'logout') {
admin_logout(); admin_logout();
header('Location: /adminer', true, 302); header('Location: /adminer', true, 302);
exit; exit;
} }
// ── DB-Auswahl (Step 1) ─────────────────────────────────────────────────── if (!isset($_SESSION['db_admin_select'])) $_SESSION['db_admin_select'] = [];
// Wir speichern Host/Port/User/Pass kurz in der Session, um die DB-Liste zu holen.
if (!isset($_SESSION['db_admin_select'])) {
$_SESSION['db_admin_select'] = [];
}
$selectError = null; $selectError = null;
$selectMsg = null; $selectMsg = null;
// Probe: Datenbanken laden
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'probe') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'probe') {
$host = trim((string)($_POST['host'] ?? '')); $host = trim((string)($_POST['host'] ?? ''));
$port = (int)($_POST['port'] ?? 3306); $port = (int)($_POST['port'] ?? 3306);
@ -145,129 +131,99 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') ==
$selectError = 'Bitte Host, Port und Benutzer angeben.'; $selectError = 'Bitte Host, Port und Benutzer angeben.';
} else { } else {
try { try {
$dsn = sprintf('mysql:host=%s;port=%d;charset=utf8mb4', $host, $port); $pdo = new PDO(sprintf('mysql:host=%s;port=%d;charset=utf8mb4', $host, $port), $user, $pass, [
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]); ]);
$dbs = $pdo->query('SHOW DATABASES')->fetchAll(PDO::FETCH_COLUMN, 0); $dbs = $pdo->query('SHOW DATABASES')->fetchAll(PDO::FETCH_COLUMN, 0);
$_SESSION['db_admin_select'] = compact('host', 'port', 'user', 'pass', 'dbs');
$_SESSION['db_admin_select'] = [ $selectMsg = 'Datenbanken geladen bitte unten eine auswählen.';
'host' => $host,
'port' => $port,
'user' => $user,
'pass' => $pass,
'dbs' => $dbs,
];
$selectMsg = 'Datenbanken geladen.';
} catch (Throwable $e) { } catch (Throwable $e) {
$selectError = 'Konnte Datenbanken nicht laden: ' . $e->getMessage(); $selectError = 'Fehler: ' . $e->getMessage();
} }
} }
} }
// Login (Step 2) // DB-Login
$error = null; $dbError = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'login') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'login') {
$host = trim((string)($_POST['host'] ?? '')); $res = admin_try_login(
$port = (int)($_POST['port'] ?? 3306); trim((string)($_POST['host'] ?? '')),
$user = trim((string)($_POST['user'] ?? '')); (int)($_POST['port'] ?? 3306),
$pass = (string)($_POST['pass'] ?? ''); trim((string)($_POST['user'] ?? '')),
$db = trim((string)($_POST['db'] ?? '')); (string)($_POST['pass'] ?? ''),
trim((string)($_POST['db'] ?? ''))
$res = admin_try_login($host, $port, $user, $pass, $db); );
if ($res['ok']) { if ($res['ok']) {
header('Location: /adminer', true, 302); header('Location: /adminer', true, 302);
exit; exit;
} }
$error = (string)($res['error'] ?? 'Login fehlgeschlagen.'); $dbError = (string)($res['error'] ?? 'Login fehlgeschlagen.');
} }
$defaults = admin_default_creds(); $defaults = admin_default_creds();
$selectState = is_array($_SESSION['db_admin_select']) ? $_SESSION['db_admin_select'] : []; $selectState = is_array($_SESSION['db_admin_select']) ? $_SESSION['db_admin_select'] : [];
$prefHost = isset($selectState['host']) ? (string)$selectState['host'] : (string)$defaults['host'];
$prefPort = isset($selectState['port']) ? (int)$selectState['port'] : (int)$defaults['port'];
$prefUser = isset($selectState['user']) ? (string)$selectState['user'] : (string)$defaults['user'];
$prefPass = isset($selectState['pass']) ? (string)$selectState['pass'] : '';
$dbList = isset($selectState['dbs']) && is_array($selectState['dbs']) ? $selectState['dbs'] : [];
// UI when not logged in
if (!admin_is_logged_in()) { if (!admin_is_logged_in()) {
$prefHost = isset($selectState['host']) ? (string)$selectState['host'] : (string)$defaults['host']; $body = '<div class="card" style="max-width:520px;margin:0 auto">';
$prefPort = isset($selectState['port']) ? (int)$selectState['port'] : (int)$defaults['port'];
$prefUser = isset($selectState['user']) ? (string)$selectState['user'] : (string)$defaults['user'];
$prefPass = isset($selectState['pass']) ? (string)$selectState['pass'] : '';
$dbList = isset($selectState['dbs']) && is_array($selectState['dbs']) ? $selectState['dbs'] : []; if ($selectMsg) $body .= '<div class="notice notice-ok">' . h($selectMsg) . '</div>';
if ($selectError)$body .= '<div class="notice notice-err">' . h($selectError) . '</div>';
if ($dbError) $body .= '<div class="notice notice-err">' . h($dbError) . '</div>';
$body = "<div class=\"top\"><h1>DB-Verwaltung</h1><span class=\"pill\">Mini-Admin</span></div>"; // Step 1
$body .= "<div class=\"card\">"; $body .= '<h2>1 · Server verbinden</h2>';
$body .= "<p class=\"muted\">Zuerst Verbindungsdaten prüfen (ohne DB), dann bekommst du eine Datenbank-Auswahl. Defaults kommen aus <code>.env</code>.</p>"; $body .= '<form method="post">'
. '<input type="hidden" name="action" value="probe">'
. '<div class="field"><label>Host</label><input name="host" value="' . h($prefHost) . '" placeholder="localhost"></div>'
. '<div class="field"><label>Port</label><input name="port" type="number" value="' . h((string)$prefPort) . '"></div>'
. '<div class="field"><label>Benutzer</label><input name="user" value="' . h($prefUser) . '"></div>'
. '<div class="field"><label>Passwort</label><input name="pass" type="password" value="' . h($prefPass) . '"></div>'
. '<button class="btn btn-ghost btn-sm" type="submit">Datenbanken laden</button>'
. '</form>';
if ($selectMsg) $body .= '<div class="ok">' . h($selectMsg) . '</div><br>'; $body .= '<hr>';
if ($selectError) $body .= '<div class="err">' . h($selectError) . '</div><br>';
if ($error) $body .= '<div class="err">' . h($error) . '</div><br>';
// Step 1: Probe // Step 2
$body .= "<h3>1) Verbindung testen & Datenbanken laden</h3>"; $body .= '<h2>2 · Datenbank auswählen & einloggen</h2>';
$body .= "<form method=\"post\">"; $body .= '<form method="post">'
$body .= "<input type=\"hidden\" name=\"action\" value=\"probe\">"; . '<input type="hidden" name="action" value="login">'
$body .= "<div class=\"grid\">"; . '<input type="hidden" name="host" value="' . h($prefHost) . '">'
$body .= "<div>"; . '<input type="hidden" name="port" value="' . h((string)$prefPort) . '">'
$body .= "<label>Host<br><input name=\"host\" value=\"" . h($prefHost) . "\"></label><br><br>"; . '<input type="hidden" name="user" value="' . h($prefUser) . '">'
$body .= "<label>Port<br><input name=\"port\" type=\"number\" value=\"" . h((string)$prefPort) . "\"></label><br><br>"; . '<input type="hidden" name="pass" value="' . h($prefPass) . '">'
$body .= "<label>Benutzer<br><input name=\"user\" value=\"" . h($prefUser) . "\"></label><br><br>"; . '<div class="field"><label>Datenbank</label>';
$body .= "<label>Passwort<br><input name=\"pass\" type=\"password\" value=\"" . h($prefPass) . "\"></label><br><br>";
$body .= "<button class=\"btn secondary\" type=\"submit\">Datenbanken laden</button>";
$body .= "</div>";
$body .= "<div>";
$body .= "<h3>Hinweise</h3>";
$body .= "<ul class=\"muted\">";
$body .= "<li>Der Zugriff ist durch den App-Login geschützt.</li>";
$body .= "<li>Für produktive Nutzung zusätzlich mit IP-Allowlist kombinieren.</li>";
$body .= "</ul>";
$body .= "</div>";
$body .= "</div>";
$body .= "</form>";
// Step 2: Login
$body .= "<hr style=\"border:0;border-top:1px solid rgba(255,255,255,.10);margin:16px 0\">";
$body .= "<h3>2) Login in Datenbank</h3>";
$body .= "<form method=\"post\">";
$body .= "<input type=\"hidden\" name=\"action\" value=\"login\">";
$body .= "<div class=\"grid\">";
$body .= "<div>";
$body .= "<label>Host<br><input name=\"host\" value=\"" . h($prefHost) . "\"></label><br><br>";
$body .= "<label>Port<br><input name=\"port\" type=\"number\" value=\"" . h((string)$prefPort) . "\"></label><br><br>";
$body .= "<label>Benutzer<br><input name=\"user\" value=\"" . h($prefUser) . "\"></label><br><br>";
$body .= "<label>Passwort<br><input name=\"pass\" type=\"password\" value=\"" . h($prefPass) . "\"></label><br><br>";
if (!empty($dbList)) { if (!empty($dbList)) {
$body .= "<label>Datenbank<br><select name=\"db\" style=\"width:100%;box-sizing:border-box;background:rgba(0,0,0,.35);color:#e5e7eb;border:1px solid rgba(255,255,255,.14);border-radius:10px;padding:10px\">"; $body .= '<select name="db">';
$selectedDb = (string)$defaults['db']; $sel = (string)$defaults['db'];
foreach ($dbList as $dbName) { foreach ($dbList as $dbName) {
$dbName = (string)$dbName; $dbName = (string)$dbName;
$sel = ($dbName === $selectedDb) ? ' selected' : ''; $body .= '<option value="' . h($dbName) . '"' . ($dbName === $sel ? ' selected' : '') . '>' . h($dbName) . '</option>';
$body .= '<option value="' . h($dbName) . '"' . $sel . '>' . h($dbName) . '</option>';
} }
$body .= "</select></label><br><br>"; $body .= '</select>';
} else { } else {
$body .= "<label>Datenbank<br><input name=\"db\" value=\"" . h((string)$defaults['db']) . "\" placeholder=\"z.B. mydb\"></label><br><br>"; $body .= '<input name="db" value="' . h((string)$defaults['db']) . '" placeholder="Datenbank-Name">';
$body .= "<div class=\"muted\" style=\"margin-top:-8px;margin-bottom:12px\">Tipp: Erst oben \"Datenbanken laden\" klicken für Vorschläge.</div>";
} }
$body .= "<button class=\"btn\" type=\"submit\">Login</button>"; $body .= '</div>'
$body .= "</div>"; . '<button class="btn" type="submit" style="width:100%">Einloggen</button>'
$body .= "</div>"; . '</form>';
$body .= "</form>";
$body .= "</div>"; $body .= '</div>';
admin_layout('DB-Verwaltung', $body); admin_layout('DB-Verwaltung', $body, 'Datenbankverbindung');
exit; exit;
} }
// Logged-in area // ── DB-VERWALTUNG (eingeloggt) ────────────────────────────────────────────
try { try {
$pdo = admin_pdo(); $pdo = admin_pdo();
$table = (string)($_GET['t'] ?? ''); $table = (string)($_GET['t'] ?? '');
$page = max(1, (int)($_GET['p'] ?? 1)); $page = max(1, (int)($_GET['p'] ?? 1));
$limit = 50; $limit = 50;
@ -276,138 +232,139 @@ try {
$msg = null; $msg = null;
$queryResultHtml = ''; $queryResultHtml = '';
// SQL Query ausführen
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'query') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'query') {
$sql = trim((string)($_POST['sql'] ?? '')); $sql = trim((string)($_POST['sql'] ?? ''));
if ($sql !== '') { if ($sql !== '') {
// Allow multiple statements? No. Keep minimal & safer.
if (preg_match('/;\s*\S/', $sql)) { if (preg_match('/;\s*\S/', $sql)) {
$msg = ['type' => 'err', 'text' => 'Bitte nur ein Statement ohne zusätzliche Semikolons ausführen.']; $msg = ['ok' => false, 'text' => 'Nur ein Statement ausführen (kein zweites Semikolon).'];
} else { } else {
try { try {
$stmt = $pdo->query($sql); $stmt = $pdo->query($sql);
if ($stmt instanceof PDOStatement) { if ($stmt instanceof PDOStatement) {
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
$queryResultHtml .= '<h3>Ergebnis</h3>'; $queryResultHtml = '<h3 style="margin-top:1.2rem">Ergebnis</h3>' . admin_render_table($rows);
$queryResultHtml .= admin_render_table($rows); $msg = ['ok' => true, 'text' => 'Query ausgeführt (' . count($rows) . ' Zeilen).'];
$msg = ['type' => 'ok', 'text' => 'Query ausgeführt.'];
} else { } else {
$msg = ['type' => 'ok', 'text' => 'Statement ausgeführt.']; $msg = ['ok' => true, 'text' => 'Statement ausgeführt.'];
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$msg = ['type' => 'err', 'text' => 'Fehler: ' . $e->getMessage()]; $msg = ['ok' => false, 'text' => $e->getMessage()];
} }
} }
} }
} }
// Build left nav tables
$tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_NUM); $tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_NUM);
$body = '<div class="top">' // ── TOP BAR ──────────────────────────────────────────────────────────
. '<div><h1>DB-Verwaltung</h1><div class="muted">eingeloggt</div></div>' $dbName = (string)($_SESSION['db_admin']['db'] ?? '');
. '<div style="display:flex;gap:10px">' $uname = (string)($_SESSION['adminer_app']['username'] ?? '');
. '<a class="btn secondary" href="/adminer?auth=logout">App-Logout</a>'
. '<a class="btn secondary" href="/adminer?a=logout">DB-Logout</a>' $body = '<div class="top-bar">'
. '<div class="top-bar-left">'
. '<span style="font-weight:700;font-size:1rem">' . h($dbName) . '</span>'
. ($uname ? '<span class="pill">' . h($uname) . '</span>' : '')
. '</div>'
. '<div class="top-bar-actions">'
. '<a class="btn btn-ghost btn-sm" href="/adminer?a=logout">DB-Logout</a>'
. '<a class="btn btn-ghost btn-sm" href="/adminer?auth=logout">Account-Logout</a>'
. '</div>' . '</div>'
. '</div>'; . '</div>';
$body .= '<div class="grid">'; // ── GRID: TABELLENLISTE + CONTENT ─────────────────────────────────────
$body .= '<div class="admin-grid">';
$body .= '<div class="card">'
. '<h3>Tabellen</h3>'
. '<div class="muted" style="margin-bottom:8px">Klick zum Anzeigen</div>';
// Linke Spalte: Tabellenliste
$body .= '<div class="card">';
$body .= '<h2>Tabellen</h2>';
if (empty($tables)) { if (empty($tables)) {
$body .= '<div class="muted">Keine Tabellen gefunden.</div>'; $body .= '<p class="muted">Keine Tabellen gefunden.</p>';
} else { } else {
$body .= '<ul class="list">'; $body .= '<ul class="nav-list">';
foreach ($tables as $row) { foreach ($tables as $row) {
$tname = (string)$row[0]; $tn = (string)$row[0];
$active = ($tname === $table) ? 'active' : ''; $cls = ($tn === $table) ? 'active' : '';
$body .= '<li><a class="' . $active . '" href="/adminer?t=' . rawurlencode($tname) . '">' . h($tname) . '</a></li>'; $body .= '<li><a class="' . $cls . '" href="/adminer?t=' . rawurlencode($tn) . '">' . h($tn) . '</a></li>';
} }
$body .= '</ul>'; $body .= '</ul>';
} }
$body .= '</div>'; $body .= '</div>';
$body .= '<div class="card">'; // Rechte Spalte: Browse + Query
$body .= '<div>';
// Notices
if ($msg) { if ($msg) {
$cls = $msg['type'] === 'ok' ? 'notice ok' : 'notice err'; $cls = $msg['ok'] ? 'notice-ok' : 'notice-err';
$body .= '<div class="' . $cls . '">' . h($msg['text']) . '</div><br>'; $body .= '<div class="notice ' . $cls . '">' . h($msg['text']) . '</div>';
} }
// Browse table // Browse
if ($table !== '') { if ($table !== '') {
// naive identifier quoting for MySQL
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) { if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) {
$body .= '<div class="err">Ungültiger Tabellenname.</div>'; $body .= '<div class="notice notice-err">Ungültiger Tabellenname.</div>';
} else { } else {
$body .= '<h3>Tabelle: <code>' . h($table) . '</code></h3>';
$stmt = $pdo->query('SELECT * FROM `' . $table . '` LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset); $stmt = $pdo->query('SELECT * FROM `' . $table . '` LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset);
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
$body .= admin_render_table($rows);
$body .= '<div style="margin-top:10px;display:flex;gap:10px">'; $body .= '<div class="card" style="margin-bottom:16px">';
if ($page > 1) { $body .= '<h2>' . h($table) . '</h2>';
$body .= '<a class="btn secondary" href="/adminer?t=' . rawurlencode($table) . '&p=' . ($page - 1) . '">← Zurück</a>'; $body .= admin_render_table($rows);
} $body .= '<div class="pagination">';
$body .= '<a class="btn secondary" href="/adminer?t=' . rawurlencode($table) . '&p=' . ($page + 1) . '">Weiter →</a>'; if ($page > 1) $body .= '<a class="btn btn-ghost btn-sm" href="/adminer?t=' . rawurlencode($table) . '&p=' . ($page - 1) . '">← Zurück</a>';
if (count($rows) === $limit) $body .= '<a class="btn btn-ghost btn-sm" href="/adminer?t=' . rawurlencode($table) . '&p=' . ($page + 1) . '">Weiter →</a>';
if ($page > 1 || count($rows) === $limit) $body .= '<span class="muted">Seite ' . $page . '</span>';
$body .= '</div>';
$body .= '</div>'; $body .= '</div>';
} }
$body .= '<hr style="border:0;border-top:1px solid rgba(255,255,255,.10);margin:16px 0">';
} }
// Query box // SQL Query Box
$body .= '<h3>SQL Query</h3>'; $body .= '<div class="card">';
$body .= '<h2>SQL Query</h2>';
$body .= '<form method="post">' $body .= '<form method="post">'
. '<input type="hidden" name="action" value="query">' . '<input type="hidden" name="action" value="query">'
. '<textarea name="sql" rows="6" spellcheck="false" placeholder="SELECT * FROM ..."></textarea>' . '<div class="field"><textarea name="sql" rows="6" placeholder="SELECT * FROM tabelle LIMIT 10"></textarea></div>'
. '<div style="margin-top:10px"><button class="btn" type="submit">Ausführen</button></div>' . '<button class="btn btn-sm" type="submit">Ausführen</button>'
. '</form>'; . '</form>';
$body .= $queryResultHtml; $body .= $queryResultHtml;
$body .= '</div>';
$body .= '</div>'; // card $body .= '</div>'; // right col
$body .= '</div>'; // grid $body .= '</div>'; // admin-grid
admin_layout('DB-Verwaltung', $body, h($dbName));
admin_layout('DB-Verwaltung', $body);
} catch (Throwable $e) { } catch (Throwable $e) {
// Session invalid, force re-login
admin_logout(); admin_logout();
admin_layout('DB-Verwaltung', '<div class="top"><h1>DB-Verwaltung</h1></div><div class="card"><div class="err">' . h($e->getMessage()) . '</div><br><a class="btn" href="/adminer">Zum Login</a></div>'); admin_layout('DB-Verwaltung',
'<div class="notice notice-err">' . h($e->getMessage()) . '</div>'
. '<p style="margin-top:1rem;text-align:center"><a class="btn btn-ghost btn-sm" href="/adminer">Zurück zum Login</a></p>',
'Fehler'
);
} }
function admin_render_table(array $rows): string function admin_render_table(array $rows): string
{ {
if (empty($rows)) { if (empty($rows)) return '<p class="muted">(keine Zeilen)</p>';
return '<div class="muted">(keine Zeilen)</div>';
}
$cols = array_keys((array)$rows[0]); $cols = array_keys((array)$rows[0]);
$html = '<table class="table"><thead><tr>'; $html = '<div style="overflow-x:auto"><table class="db-table"><thead><tr>';
foreach ($cols as $c) { foreach ($cols as $c) $html .= '<th>' . h((string)$c) . '</th>';
$html .= '<th>' . h((string)$c) . '</th>';
}
$html .= '</tr></thead><tbody>'; $html .= '</tr></thead><tbody>';
foreach ($rows as $r) { foreach ($rows as $r) {
$html .= '<tr>'; $html .= '<tr>';
foreach ($cols as $c) { foreach ($cols as $c) {
$v = $r[$c] ?? null; $v = $r[$c] ?? null;
if ($v === null) { $cell = $v === null
$cell = '<span class="muted">NULL</span>'; ? '<span class="null-val">NULL</span>'
} else { : (strlen((string)$v) > 300 ? h(substr((string)$v, 0, 300)) . '…' : h((string)$v));
$s = (string)$v;
$cell = strlen($s) > 500 ? h(substr($s, 0, 500)) . '…' : h($s);
}
$html .= '<td>' . $cell . '</td>'; $html .= '<td>' . $cell . '</td>';
} }
$html .= '</tr>'; $html .= '</tr>';
} }
$html .= '</tbody></table></div>';
$html .= '</tbody></table>';
return $html; return $html;
} }

View File

@ -6,16 +6,72 @@ function h($s)
return htmlspecialchars((string)$s, ENT_QUOTES); return htmlspecialchars((string)$s, ENT_QUOTES);
} }
function admin_layout($title, $bodyHtml) function admin_layout($title, $bodyHtml, $subtitle = 'Datenbankverwaltung')
{ {
echo "<!doctype html>\n"; ?>
echo "<html lang=\"de\">\n<head>\n"; <!DOCTYPE html>
echo "<meta charset=\"utf-8\">\n"; <html lang="de">
echo "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"; <head>
echo '<title>' . h($title) . "</title>\n"; <meta charset="UTF-8">
echo "<link rel=\"stylesheet\" href=\"/adminer/adminer.css\">\n"; <meta name="viewport" content="width=device-width, initial-scale=1.0">
echo "</head>\n<body>\n"; <title><?= h($title) ?> Fabian Schieder</title>
echo "<div class=\"wrap\">\n"; <link rel="stylesheet" href="/adminer/adminer.css">
echo $bodyHtml; </head>
echo "</div>\n</body>\n</html>"; <body>
<canvas id="particle-canvas"></canvas>
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="orb orb-3"></div>
<div class="orb orb-4"></div>
<div class="background-blur"></div>
<div class="wrap">
<div class="admin-header">
<div class="admin-avatar">FS</div>
<div class="admin-title"><?= h($title) ?></div>
<div class="admin-subtitle"><?= h($subtitle) ?></div>
</div>
<?= $bodyHtml ?>
<footer style="margin-top:3rem;padding-top:1.5rem;color:var(--text-muted);font-size:.8rem;text-align:center;border-top:1px solid var(--border)">
&copy; <?= date('Y') ?> Fabian Schieder &mdash;
<a href="/" style="color:var(--text-muted)">&larr; Zurück zur Startseite</a>
</footer>
</div>
<script>
// ── 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(.8,2.2),dx:rand(-.25,.25),dy:rand(-.35,-.08),alpha:rand(.2,.8),fade:rand(.002,.006),color:COLORS[Math.floor(Math.random()*COLORS.length)]};}
function init(){resize();particles=Array.from({length:80},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(.2,.7)});
}
ctx.globalAlpha=1;
requestAnimationFrame(draw);
}
window.addEventListener('resize',resize);
init();draw();
})();
</script>
</body>
</html>
<?php
} }