This commit is contained in:
2025-12-25 01:50:07 +01:00
parent 5d7dcfd950
commit 3b4b5cad3e
11 changed files with 743 additions and 372 deletions

View File

@@ -12,8 +12,8 @@ declare(strict_types=1);
// ------------------------------------------------------------
// 1) Driver selection (choose one)
// ------------------------------------------------------------
$driver = 'pgsql';
// $driver = 'mysql';
//$driver = 'pgsql';
$driver = 'mysql';
// $driver = 'sqlite';
// ------------------------------------------------------------
@@ -44,14 +44,14 @@ $mysql = [
'driver' => 'mysql',
'host' => 'localhost',
'port' => 3306,
'dbname' => 'mydb',
'dbname' => 'd0444c21',
'charset' => 'utf8mb4',
// Alternative to host/port:
// 'unix_socket' => '/var/run/mysqld/mysqld.sock',
'user' => 'myuser',
'password' => 'secret',
'user' => 'd0444c21',
'password' => 'Rnßü7ROxFTxmOazLNhz/',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,

View File

@@ -12,8 +12,8 @@ declare(strict_types=1);
// ------------------------------------------------------------
// 1) Driver selection (choose one)
// ------------------------------------------------------------
$driver = 'pgsql';
// $driver = 'mysql';
//$driver = 'pgsql';
$driver = 'mysql';
// $driver = 'sqlite';
// ------------------------------------------------------------
@@ -31,7 +31,7 @@ $pgsql = [
'schema' => 'public',
'user' => 'd0444c25',
'password' => '/7ü9+§ÄfkiQvGPr§2Op7',
'password' =>secret '/7ü9+§ÄfkiQvGPr§2Op7',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
@@ -44,14 +44,14 @@ $mysql = [
'driver' => 'mysql',
'host' => 'localhost',
'port' => 3306,
'dbname' => 'mydb',
'dbname' => 'd0444c25',
'charset' => 'utf8mb4',
// Alternative to host/port:
// 'unix_socket' => '/var/run/mysqld/mysqld.sock',
'user' => 'myuser',
'password' => 'secret',
'user' => 'd0444c25',
'password' => '/7ü9+§ÄfkiQvGPr§2Op7',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,

View File

@@ -1,39 +1,190 @@
<?php
$app = app();
// Example: register assets from inside a landing template
$app->assets()->addStyle('/assets/app.css', 'early');
$app->assets()->addScript('/assets/app.js', 'footer', true);
$flash = $app->flash()->get();
?>
<div class="card">
<div class="pill">env: <?= htmlspecialchars($app->config()->env, ENT_QUOTES) ?></div>
<h1 style="margin-top: .75rem;"><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></h1>
<p class="muted"><?= htmlspecialchars(t('common.intro'), ENT_QUOTES) ?></p>
<main>
<?php if ($flash): ?>
<div style="margin: 1rem 0; padding: .75rem 1rem; border: 1px solid #ddd; border-radius: 12px;">
<div class="toast-bar">
<strong><?= htmlspecialchars($flash['type'], ENT_QUOTES) ?>:</strong>
<?= htmlspecialchars($flash['message'], ENT_QUOTES) ?>
</div>
<?php endif; ?>
<div class="grid" style="margin-top: 1rem;">
<div>
<h3 style="margin: 0 0 .5rem 0;">Runtime</h3>
<div><strong>Current URL:</strong> <?= htmlspecialchars($app->request()->currentUrl(), ENT_QUOTES) ?></div>
<div><strong>Client-ID:</strong> <code><?= htmlspecialchars($GLOBALS['client_id'] ?? '', ENT_QUOTES) ?></code></div>
<section class="hero">
<div class="container hero__grid">
<div class="hero__text">
<p class="eyebrow">Gemeinsam stark</p>
<h1>Treffen für Väter mit und ohne Kinder. Lokal, sicher, verschlüsselt.</h1>
<p class="lede">Finde andere Väter in deiner Nähe, plane Events oder tritt bestehenden Treffen bei. Daten bleiben geschützt, du entscheidest die Sichtbarkeit.</p>
<div class="hero__actions">
<button class="btn">Kostenlos registrieren</button>
<button class="btn ghost" id="scrollToEvents">Events in deiner Nähe</button>
</div>
<div class="hero__meta">
<div class="chip inline">Verschlüsselte Profile</div>
<div class="chip inline">Kinderinfos separat freigeben</div>
<div class="chip inline">Events nur für Members erstellen</div>
</div>
</div>
<div class="hero__card card">
<div class="badge">Schnellsuche</div>
<h3>Events in deiner Region</h3>
<div class="form-row">
<label class="label" for="locInput">PLZ oder Ort</label>
<input id="locInput" class="input" placeholder="z. B. 10437 oder Berlin" />
</div>
<div class="form-row">
<label class="label" for="topicSelect">Thema</label>
<select id="topicSelect" class="select">
<option value="">Alle</option>
<option value="outdoor">Outdoor / Spielplatz</option>
<option value="kaffee">Kaffee & Austausch</option>
<option value="sport">Sport</option>
<option value="workshop">Workshop</option>
</select>
</div>
<div class="form-row">
<label class="label" for="ageSelect">Alter der Kinder</label>
<select id="ageSelect" class="select">
<option value="">Alle</option>
<option value="baby">0-2 Jahre</option>
<option value="kids">3-6 Jahre</option>
<option value="school">7-12 Jahre</option>
</select>
</div>
<button class="btn block" id="btnSearch">Events anzeigen</button>
<button class="btn ghost block" id="btnGeo">Standort automatisch ermitteln</button>
<p class="muted small">Geodaten werden nur zur Anzeige verwendet.</p>
</div>
</div>
<div>
<h3 style="margin: 0 0 .5rem 0;">Actions</h3>
<form method="post" action="/action/flash">
<button type="submit" style="padding:.6rem 1rem; border-radius: 12px; border: 1px solid #ddd; background: white; cursor:pointer;">
Set flash message
</button>
</form>
<p class="muted" style="margin-top:.5rem;">Flash uses SessionManager, no direct globals.</p>
</section>
<section class="container section" id="events">
<div class="section__head">
<div>
<p class="eyebrow">Termine entdecken</p>
<h2>Upcoming Events in deiner Nähe</h2>
<p class="muted">Gäste sehen nur Basisinfos. Als Mitglied siehst du vollständige Details, kannst zusagen und neue Treffen anlegen.</p>
</div>
<div class="chips">
<button class="chip" data-filter="all">Alle</button>
<button class="chip" data-filter="outdoor">Outdoor</button>
<button class="chip" data-filter="kaffee">Kaffee</button>
<button class="chip" data-filter="sport">Sport</button>
<button class="chip" data-filter="workshop">Workshops</button>
</div>
</div>
</div>
</div>
<div class="results" id="eventList"></div>
<div class="surface border rounded p-4 mt-3">
<div class="flex between center-y gap-12">
<div>
<strong>Nur für eingeloggte Mitglieder</strong>
<p class="muted small">Volle Beschreibung, Kontakt, Kinder-Infos und Anmeldeoptionen.</p>
</div>
<button class="btn">Jetzt anmelden</button>
</div>
</div>
</section>
<section class="section alt" id="profil">
<div class="container split">
<div>
<p class="eyebrow">Profil & Datenschutz</p>
<h2>Du entscheidest, was sichtbar ist.</h2>
<p class="muted">Papa-Daten und Kinder-Daten sind getrennt. Sichtbarkeiten steuerst du per Schalter, sensible Felder werden verschlüsselt abgelegt.</p>
<div class="grid grid-2 mt-2">
<div class="surface border rounded p-4">
<h3 class="mt-0">Papa-Sichtbarkeit</h3>
<ul class="list">
<li><strong>basic:</strong> Name, Region, Kinderanzahl</li>
<li><strong>papa:</strong> + Beruf, Sprachen, About</li>
<li><strong>papa_contact:</strong> + Telefon/Email</li>
</ul>
</div>
<div class="surface border rounded p-4">
<h3 class="mt-0">Kinder separat</h3>
<ul class="list">
<li><strong>hidden:</strong> keine Details</li>
<li><strong>age_only:</strong> nur Alter/Altersgruppe</li>
<li><strong>details:</strong> Vorname, Geschlecht, Alter</li>
</ul>
</div>
</div>
<div class="pill-row mt-2">
<span class="pill">Verschlüsselung mit libsodium</span>
<span class="pill">Nonce pro Feld</span>
<span class="pill">Key aus ENV</span>
</div>
</div>
<div class="card privacy-card">
<div class="badge">Sicherheit</div>
<h3>Wie wird gespeichert?</h3>
<ul class="list">
<li>Symmetrische Verschlüsselung (XChaCha20-Poly1305)</li>
<li>Key nur im Backend (ENV), nicht in der DB</li>
<li>Felder: Kontakt, Beruf, Sprachen, About, Kinder</li>
<li>Events unterscheiden Public vs. Members-Details</li>
</ul>
<p class="muted small">Bei Datenabzug bleiben Felder unlesbar. Zugriff nur mit Key.</p>
</div>
</div>
</section>
<section class="container section" id="sicherheit">
<div class="section__head">
<div>
<p class="eyebrow">Ablauf</p>
<h2>So funktionierts</h2>
</div>
</div>
<div class="grid grid-3">
<div class="card step">
<div class="step__icon">1</div>
<h3>Profil anlegen</h3>
<p class="muted small">Papa-Daten ausfüllen, Kinder optional. Sichtbarkeit pro Bereich einstellen.</p>
</div>
<div class="card step">
<div class="step__icon">2</div>
<h3>Events finden</h3>
<p class="muted small">Suche nach Thema, Alter oder Region. Gäste sehen nur Basisinfos.</p>
</div>
<div class="card step">
<div class="step__icon">3</div>
<h3>Treffen planen</h3>
<p class="muted small">Als Mitglied neue Events anlegen, andere einladen, Teilnahme verwalten.</p>
</div>
</div>
</section>
<section class="section alt" id="faq">
<div class="container split">
<div>
<p class="eyebrow">Noch Fragen?</p>
<h2>FAQ</h2>
<div class="faq">
<details open>
<summary>Wer sieht meine Daten?</summary>
<p>Du stellst die Sichtbarkeit: basic/papa/papa_contact für Papa-Daten, Kinder separat (hidden/age_only/details). Gäste sehen nur rudimentäre Event-Infos.</p>
</details>
<details>
<summary>Wie funktionieren Events?</summary>
<p>Eingeloggt kannst du Events erstellen. Andere Mitglieder melden sich an. Gäste sehen nur Teaser, Ort grob und Datum.</p>
</details>
<details>
<summary>Welche Daten sind verschlüsselt?</summary>
<p>Kontakt, Beruf, Sprachen, About, Kinder-Vornamen/Geschlecht/Notizen werden app-seitig verschlüsselt gespeichert.</p>
</details>
</div>
</div>
<div class="card cta-card">
<div class="badge">Bereit?</div>
<h3>Jetzt loslegen</h3>
<p class="muted small">Registriere dich kostenlos, lege dein Profil an und vernetze dich mit anderen Vätern.</p>
<div class="stack gap-12">
<button class="btn block">Registrieren</button>
<button class="btn ghost block">Login</button>
</div>
</div>
</div>
</section>
</main>

View File

@@ -1,22 +1,26 @@
<?php
/** @var \App\App $app */
$app = app();
// globale Assets (Brand-Styles & JS)
$app->assets()->addStyle('/assets/css/styles.css', 'early');
$app->assets()->addStyle('/assets/css/app.css', 'normal');
$app->assets()->addScript('/assets/js/app.js', 'footer', true, false);
$childGender = $_SESSION['child_gender_summary'] ?? ''; // 'male' | 'female' | 'mixed' | ''
if (!in_array($childGender, ['male', 'female', 'mixed'], true)) {
$childGender = '';
}
?>
<!doctype html>
<html lang="en">
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></title>
<meta name="description" content="Papa-Kind-Treff: Väter vernetzen sich für Treffen mit und ohne Kinder, Events in der Nähe entdecken und sicher Kontakte knüpfen.">
<?php asset_styles(); ?>
<?php asset_scripts('header'); ?>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 2rem; }
.card { border: 1px solid #ddd; border-radius: 12px; padding: 1.25rem; max-width: 820px; }
.muted { color: #555; }
.pill { display: inline-block; padding: .25rem .5rem; border-radius: 999px; border: 1px solid #ddd; font-size: .9rem; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 720px) { .grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<body data-auth="<?= isset($_SESSION['user_id']) ? '1' : '0' ?>" data-child-gender="<?= htmlspecialchars($childGender, ENT_QUOTES) ?>">
<?php tpl('nav', 'structure'); ?>

View File

@@ -0,0 +1,44 @@
<?php
$app = app();
$isLoggedIn = isset($_SESSION['user_id']);
?>
<header class="site-header">
<div class="container nav-row">
<div class="brand">
<img data-logo-img src="/assets/bilder/logo_male.png" alt="Papa-Kind-Treff Logo" class="brand__logo">
<div class="brand__text">
<span class="brand__name">Papa-Kind-Treff</span>
<span class="brand__tag">Väter vernetzen</span>
</div>
</div>
<nav class="nav-links" aria-label="Hauptmenü">
<a href="#events">Events</a>
<a href="#profil">Profil</a>
<a href="#sicherheit">Sicherheit</a>
<a href="#faq">FAQ</a>
</nav>
<div class="nav-actions">
<?php if ($isLoggedIn): ?>
<button class="btn ghost" type="button">Dashboard</button>
<button class="btn" type="button">Neues Event</button>
<?php else: ?>
<button class="btn ghost" type="button">Anmelden</button>
<button class="btn" type="button">Kostenlos registrieren</button>
<?php endif; ?>
<button class="menu-toggle" aria-label="Menü öffnen">☰</button>
</div>
</div>
<div class="mobile-menu" id="mobileMenu">
<a href="#events">Events</a>
<a href="#profil">Profil</a>
<a href="#sicherheit">Sicherheit</a>
<a href="#faq">FAQ</a>
<?php if ($isLoggedIn): ?>
<button class="btn ghost" type="button">Dashboard</button>
<button class="btn block" type="button">Neues Event</button>
<?php else: ?>
<button class="btn ghost" type="button">Anmelden</button>
<button class="btn block" type="button">Kostenlos registrieren</button>
<?php endif; ?>
</div>
</header>

View File

@@ -1 +1,130 @@
/* minimal css placeholder */
body {
margin: 0;
font-family: "Manrope", "Segoe UI", system-ui, -apple-system, sans-serif;
background: var(--color-bg);
color: var(--color-text);
}
.site-header {
position: sticky;
top: 0;
z-index: 50;
background: rgba(255, 255, 255, 0.95);
border-bottom: 1px solid var(--color-border);
backdrop-filter: blur(8px);
}
.nav-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand__logo { width: 46px; height: 46px; object-fit: contain; }
.brand__text { display: flex; flex-direction: column; line-height: 1.1; }
.brand__name { font-weight: 700; letter-spacing: -0.3px; }
.brand__tag { color: var(--color-muted); font-size: 13px; }
.nav-links {
display: flex;
align-items: center;
gap: 18px;
font-weight: 600;
}
.nav-links a { color: var(--color-text); }
.nav-links a:hover { color: var(--color-primary); }
.nav-actions { display: flex; align-items: center; gap: 10px; }
.menu-toggle { display: none; background: transparent; border: 1px solid var(--color-border); padding: 8px 10px; border-radius: var(--radius-sm); }
.mobile-menu { display: none; padding: 12px 16px; border-top: 1px solid var(--color-border); background: #fff; }
.mobile-menu a, .mobile-menu button { display: block; width: 100%; text-align: left; margin-bottom: 10px; }
@media (max-width: 900px){
.nav-links { display: none; }
.menu-toggle { display: inline-flex; }
.nav-actions .btn { display: none; }
.mobile-menu.open { display: block; }
}
.hero {
padding: 64px 0;
background: linear-gradient(135deg, #fdf6e6, #f1e8de);
border-bottom: 1px solid var(--color-border);
}
.hero__grid { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 32px; align-items: center; }
.hero__text h1 { margin: 12px 0 10px 0; font-size: clamp(28px, 4vw, 42px); line-height: 1.1; }
.hero__text .lede { color: var(--color-muted); font-size: 17px; }
.hero__actions { display:flex; gap: 12px; flex-wrap: wrap; margin: 18px 0; }
.hero__meta { display:flex; gap:8px; flex-wrap: wrap; }
.hero__card { padding: 20px; background: #fff; border:1px solid var(--color-border); box-shadow: var(--shadow-card); }
.eyebrow { text-transform: uppercase; letter-spacing: 1px; font-size: 12px; color: var(--color-muted); margin: 0; }
.lede { margin: 0; }
.muted.small { font-size: 13px; }
.section { padding: 64px 0; }
.section.alt { background: #ffffff; border-block: 1px solid var(--color-border); }
.section__head { display:flex; justify-content:space-between; align-items:flex-start; gap: 16px; flex-wrap: wrap; }
.split { display:grid; grid-template-columns: 1.1fr 0.9fr; gap: 24px; align-items: start; }
@media (max-width: 960px){ .split, .hero__grid { grid-template-columns: 1fr; } }
.card { background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 18px; box-shadow: var(--shadow-card); }
.badge { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; background: var(--color-accent-soft); color: var(--color-highlight); border-radius: 999px; font-weight: 700; font-size: 12px; letter-spacing: .3px; }
.hero__card .form-row { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
.pill-row { display:flex; flex-wrap: wrap; gap: 8px; }
.pill { display:inline-flex; align-items:center; gap:6px; padding: 6px 10px; border-radius: 999px; border: 1px solid var(--color-border); background:#fff; font-size: 13px; }
.list { margin: 0; padding-left: 18px; display: grid; gap: 8px; }
.list li { color: var(--color-text); }
.privacy-card { background: linear-gradient(145deg, #ffffff, #f7f0e6); }
.step { text-align: left; }
.step__icon { width: 34px; height:34px; border-radius: 10px; background: var(--color-accent); color: #fff; display:flex; align-items:center; justify-content:center; font-weight:700; margin-bottom: 8px; }
.faq details { border:1px solid var(--color-border); border-radius: var(--radius-sm); padding: 10px 12px; background: #fff; }
.faq summary { cursor:pointer; font-weight: 600; }
.faq p { margin: 8px 0 0 0; color: var(--color-muted); }
.toast-bar { max-width: var(--maxw); margin: 16px auto; background: #fff; border:1px solid var(--color-border); border-radius: var(--radius-md); padding: 12px 16px; box-shadow: var(--shadow-card); }
.btn { background: var(--color-primary); color: var(--color-primary-contrast); }
.btn:hover { transform: translateY(-1px); transition: transform 120ms ease; }
.btn.ghost { border:1px solid var(--color-border); color: var(--color-text); background: #fff; }
.chip.inline { padding: 6px 10px; }
.results .card { padding: 0; }
.event__body { padding: 16px; display:grid; gap:8px; }
.event__meta { display:flex; gap: 12px; flex-wrap: wrap; font-size: 13px; color: var(--color-muted); }
.event__tags { display:flex; gap: 6px; flex-wrap: wrap; }
.event__access { font-size: 12px; color: var(--color-highlight); font-weight: 700; text-transform: uppercase; letter-spacing: .5px; }
.label { font-size: 13px; color: var(--color-muted); }
.input, .select { border-radius: var(--radius-sm); border-color: var(--color-border); background: #fff; }
.input:focus, .select:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(52,72,90,0.14); }
.cta-card { background: linear-gradient(135deg, #fdf4e0, #ffffff); }
.flex { display:flex; }
.between { justify-content: space-between; }
.center-y { align-items: center; }
.gap-12 { gap: 12px; }
.stack { display:flex; flex-direction: column; }
@media (max-width: 720px){
.nav-row { padding: 12px 0; }
.hero { padding: 40px 0; }
.section { padding: 48px 0; }
}

View File

@@ -21,20 +21,21 @@ input, textarea, select { font: inherit; color: inherit; }
/* 2) Tokens */
:root {
--color-bg: #f8fafc; /* primary soft */
--color-bg: #f7f2ea;
--color-surface: #ffffff;
--color-border: #e5e7eb; /* slate-200 */
--color-muted: #64748b; /* slate-500 */
--color-text: #0f172a; /* slate-900 */
--color-primary: #111827; /* neutral-900 */
--color-border: #e5d8c1;
--color-muted: #5f6b7a;
--color-text: #26323f;
--color-primary: #34485a; /* Basisfarbe */
--color-primary-contrast: #ffffff;
--color-accent: #f59e0b; /* amber-500 */
--color-accent-soft: #fff7ed; /* amber-50 */
--color-accent: #e9b049; /* warmes Highlight */
--color-accent-soft: #fdf4e0;
--color-highlight: #99433f; /* Akzent 2 */
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 18px;
--shadow-card: 0 1px 2px rgba(0,0,0,0.04), 0 6px 24px rgba(0,0,0,0.06);
--maxw: 1100px;
--shadow-card: 0 1px 2px rgba(0,0,0,0.04), 0 10px 32px rgba(0,0,0,0.08);
--maxw: 1180px;
}
/******************** 3) Utilities ********************/
@@ -58,7 +59,7 @@ input, textarea, select { font: inherit; color: inherit; }
.clamp-3 { display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }
/******************** 4) Layout ********************/
.header { position: sticky; top:0; z-index: 40; backdrop-filter: blur(4px); background: rgba(255,255,255,0.85); border-bottom:1px solid var(--color-border); }
.header { position: sticky; top:0; z-index: 40; backdrop-filter: blur(4px); background: rgba(255,255,255,0.9); border-bottom:1px solid var(--color-border); }
.header .nav { display:flex; gap:24px; align-items:center; }
.header .brand { display:flex; align-items:center; gap:8px; font-weight:600; font-size:18px; letter-spacing: -0.2px; }
.header .menu-btn { display:none; border-radius: 10px; padding:8px; }

View File

@@ -1 +1,206 @@
console.log('mini example loaded');
document.addEventListener('DOMContentLoaded', () => {
const body = document.body;
const isLoggedIn = body.dataset.auth === '1';
const childGender = body.dataset.childGender || '';
// Logo-Logik
const pickLogo = (gender) => {
if (gender === 'female') return 'logo_female.png';
if (gender === 'male') return 'logo_male.png';
return Math.random() < 0.5 ? 'logo_female.png' : 'logo_male.png';
};
const chosenLogo = pickLogo(childGender);
document.querySelectorAll('[data-logo-img]').forEach(img => {
img.src = `/assets/bilder/${chosenLogo}`;
});
// Mobile Menü
const mobileMenu = document.getElementById('mobileMenu');
document.querySelectorAll('.menu-toggle').forEach(btn => {
btn.addEventListener('click', () => {
mobileMenu?.classList.toggle('open');
});
});
// Scroll zu Events
const scrollBtn = document.getElementById('scrollToEvents');
if (scrollBtn) {
scrollBtn.addEventListener('click', () => {
document.getElementById('events')?.scrollIntoView({ behavior: 'smooth' });
});
}
// Demo-Events
const events = [
{
id: 1,
title: 'Spielplatzrunde im Park',
teaser: 'Lockeres Treffen mit Kaffee, Sandspielzeug und Picknick.',
description: 'Wir treffen uns am großen Spielplatz im Kiez. Bringt Snacks mit, wir teilen. Für alle Altersstufen offen.',
city: 'Berlin',
zip: '10437',
region: 'Prenzlauer Berg',
topic: 'outdoor',
startsAt: '2025-08-10T10:00:00',
allowKids: true,
ageGroup: 'kids',
visibility: 'public',
contact: 'papa-berlin@example.com',
locationLabel: 'Mauerpark',
},
{
id: 2,
title: 'Väter-Kaffee & Austausch',
teaser: 'Elternzeit, Job, Schlaf wir reden über alles.',
description: 'Reservierter Tisch im Café. Fokus auf Austausch unter Vätern, Kinder optional.',
city: 'Hamburg',
zip: '22767',
region: 'Altona',
topic: 'kaffee',
startsAt: '2025-08-12T19:00:00',
allowKids: false,
ageGroup: '',
visibility: 'members',
contact: 'altona-dads@example.com',
locationLabel: 'Café Elbseite',
},
{
id: 3,
title: 'Sport & Spiel im Park',
teaser: 'Ballspiele und leichtes Workout, Kinder toben mit.',
description: 'Wir bringen Bälle und Slackline. Warm-up, danach freies Spiel. Bitte Wasser mitbringen.',
city: 'München',
zip: '80804',
region: 'Schwabing',
topic: 'sport',
startsAt: '2025-08-15T11:00:00',
allowKids: true,
ageGroup: 'school',
visibility: 'public',
contact: 'schwabing-sport@example.com',
locationLabel: 'Luitpoldpark',
},
{
id: 4,
title: 'Workshop: Erste Hilfe für Kids',
teaser: 'Erste Hilfe Basics speziell für Kinder-Unfälle.',
description: 'Zertifizierter Trainer, kleiner Materialbeitrag. Kinder können mitgebracht werden.',
city: 'Köln',
zip: '50667',
region: 'Innenstadt',
topic: 'workshop',
startsAt: '2025-08-20T18:30:00',
allowKids: true,
ageGroup: 'kids',
visibility: 'members',
contact: 'koeln-workshop@example.com',
locationLabel: 'Familienzentrum Mitte',
},
];
const state = { topic: 'all', age: '', region: '', query: '' };
const el = {
list: document.getElementById('eventList'),
chips: document.querySelectorAll('[data-filter]'),
locInput: document.getElementById('locInput'),
topicSelect: document.getElementById('topicSelect'),
ageSelect: document.getElementById('ageSelect'),
btnSearch: document.getElementById('btnSearch'),
btnGeo: document.getElementById('btnGeo'),
};
const fmtDate = (iso) => {
const d = new Date(iso);
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
};
const tag = (label) => `<span class="badge">${label}</span>`;
const renderCard = (item) => {
const guest = !isLoggedIn;
const access = item.visibility === 'members' && guest ? '<div class="event__access">Nur für Mitglieder</div>' : '';
const desc = guest ? `<p class="muted">Melde dich an, um die volle Beschreibung zu sehen.</p>` : `<p>${item.description}</p>`;
const contact = !guest ? `<div class="muted small">Kontakt: ${item.contact}</div>` : '';
const kids = item.allowKids ? 'Mit Kindern' : 'Ohne Kinder';
const tags = [
tag(item.topic === 'kaffee' ? 'Kaffee & Austausch' : item.topic.charAt(0).toUpperCase() + item.topic.slice(1)),
tag(kids),
item.ageGroup ? tag(`Alter: ${item.ageGroup}`) : '',
].filter(Boolean).join('');
return `
<article class="card">
<div class="event__body">
${access}
<div class="event__meta">
<span>${fmtDate(item.startsAt)}</span>
<span>📍 ${item.region || item.city}</span>
<span>${item.visibility === 'public' ? 'Öffentlich' : 'Mitglieder'}</span>
</div>
<h3>${item.title}</h3>
<p class="muted">${guest ? item.teaser : item.description.slice(0, 140) + '...'}</p>
${guest ? '' : desc}
<div class="event__tags">${tags}</div>
${contact}
<div class="flex gap-12">
<button class="btn ghost">Details</button>
${!guest ? '<button class="btn">Teilnehmen</button>' : '<button class="btn">Anmelden</button>'}
</div>
</div>
</article>`;
};
const matchesFilter = (ev) => {
const topicOk = state.topic === 'all' || state.topic === ev.topic;
const ageOk = !state.age || ev.ageGroup === state.age || (state.age === 'baby' && ev.ageGroup === 'kids'); // simple fallback
const regionOk = !state.region || ev.region.toLowerCase().includes(state.region) || ev.city.toLowerCase().includes(state.region) || ev.zip.startsWith(state.region);
const queryOk = !state.query || (ev.title + ev.teaser + ev.description + ev.city + ev.region).toLowerCase().includes(state.query);
return topicOk && ageOk && regionOk && queryOk;
};
const renderEvents = () => {
if (!el.list) return;
const filtered = events.filter(matchesFilter);
el.list.innerHTML = filtered.map(renderCard).join('') || '<p class="muted">Keine Events gefunden.</p>';
};
if (el.chips.length > 0) {
el.chips[0].classList.add('active');
}
el.chips.forEach(chip => {
chip.addEventListener('click', () => {
el.chips.forEach(c => c.classList.remove('active'));
chip.classList.add('active');
state.topic = chip.dataset.filter;
renderEvents();
});
});
if (el.btnSearch) {
el.btnSearch.addEventListener('click', () => {
state.region = (el.locInput?.value || '').trim().toLowerCase();
state.topic = el.topicSelect?.value || 'all';
state.age = el.ageSelect?.value || '';
renderEvents();
});
}
if (el.btnGeo) {
el.btnGeo.addEventListener('click', () => {
if (!navigator.geolocation) {
alert('Geolocation wird nicht unterstützt.');
return;
}
navigator.geolocation.getCurrentPosition(
() => {
state.region = 'nähe';
renderEvents();
},
() => alert('Standort konnte nicht ermittelt werden.')
);
});
}
renderEvents();
});

313
public/page/index.php Executable file → Normal file
View File

@@ -1,310 +1,5 @@
<!doctype html>
<html lang="de" class="scroll-smooth">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>schwarzesbrett.online Digitales Schwarzes Brett</title>
<meta name="description" content="Kostenlose Aushänge & Nachbarschaftshilfe lokal, einfach, ohne Schnickschnack." />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ctext x='2' y='18' font-size='16'%3E%F0%9F%93%8C%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="./assets/styles.css" />
</head>
<body>
<!-- Header -->
<header class="header">
<div class="container" style="display:flex;align-items:center;justify-content:space-between;height:64px;">
<a href="#" class="brand" aria-label="Startseite">
<span style="font-size:22px;">📌</span>
<span>schwarzesbrett<span style="color:#94a3b8">.online</span></span>
</a>
<nav class="nav">
<a href="#kategorien">Kategorien</a>
<a href="#neu">Neueste</a>
<a href="#sofunktionierts">So funktionierts</a>
<button id="openCreate" class="btn"> Inserat erstellen</button>
<a href="#" class="text-muted">Login</a>
</nav>
<button id="openMenu" class="menu-btn" aria-label="Menü öffnen"></button>
</div>
<div id="mobileMenu" class="mobile-menu">
<div class="container" style="display:flex;flex-direction:column;gap:12px;padding-block:12px;">
<a href="#kategorien">Kategorien</a>
<a href="#neu">Neueste</a>
<a href="#sofunktionierts">So funktionierts</a>
<button id="openCreateMobile" class="btn" style="width:fit-content;"> Inserat erstellen</button>
</div>
</div>
</header>
<?php
declare(strict_types=1);
<!-- Hero -->
<section class="hero">
<div class="container" style="padding-block:48px;display:grid;gap:24px;grid-template-columns: 1.2fr .8fr;">
<div>
<h1>Dein digitales Schwarzes Brett für <span id="heroOrt" style="text-decoration: underline wavy #f59e0b;">deine Nachbarschaft</span></h1>
<p class="sub mt-2">Inserate, Nachbarschaftshilfe & Veranstaltungen lokal, übersichtlich und frei von Spam.</p>
<form id="searchForm" class="search mt-3" aria-label="Suche">
<label class="sr-only" for="q">Stichwort</label>
<input id="q" class="input" type="search" placeholder="Suche nach Stichwort (z.B. Fahrrad, Babysitter)" />
<label class="sr-only" for="zip">PLZ</label>
<input id="zip" class="input" type="text" inputmode="numeric" maxlength="5" placeholder="PLZ" />
<button class="btn" type="submit">Suchen</button>
</form>
<div class="mt-2 text-muted" style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
<button id="detectLocation" class="btn ghost">PLZ automatisch erkennen</button>
<button id="openCreateHero" class="btn ghost">Jetzt kostenlos Aushang erstellen</button>
</div>
</div>
<div>
<div class="card p-4">
<div class="grid grid-3">
<button data-cat="Flohmarkt" class="note pin p-3 rounded">
<div style="font-weight:600;">🛒 Flohmarkt</div>
<div class="text-muted" style="font-size:12px;">Verkaufen & Finden</div>
</button>
<button data-cat="Jobs" class="note pin p-3 rounded">
<div style="font-weight:600;">💼 Jobs</div>
<div class="text-muted" style="font-size:12px;">Mini- & Nebenjobs</div>
</button>
<button data-cat="Nachbarschaftshilfe" class="note pin p-3 rounded">
<div style="font-weight:600;">🤝 Hilfe</div>
<div class="text-muted" style="font-size:12px;">Leihen, Mitnehmen</div>
</button>
<button data-cat="Veranstaltungen" class="note pin p-3 rounded">
<div style="font-weight:600;">🎉 Veranstaltungen</div>
<div class="text-muted" style="font-size:12px;">Heute & bald</div>
</button>
<button data-cat="Immobilien" class="note pin p-3 rounded">
<div style="font-weight:600;">🏡 Immobilien</div>
<div class="text-muted" style="font-size:12px;">Miete & WG</div>
</button>
<button data-cat="Sonstiges" class="note pin p-3 rounded">
<div style="font-weight:600;">📌 Sonstiges</div>
<div class="text-muted" style="font-size:12px;">Alles andere</div>
</button>
</div>
</div>
</div>
</div>
</section>
<!-- Kategorien -->
<section id="kategorien" class="container" style="padding-block:24px;">
<h2 style="font-size:20px;font-weight:600;" class="mb-2">Entdecke Kategorien</h2>
<div class="chips">
<button class="chip" data-filter="">Alle</button>
<button class="chip" data-filter="Flohmarkt">🛒 Flohmarkt</button>
<button class="chip" data-filter="Jobs">💼 Jobs</button>
<button class="chip" data-filter="Nachbarschaftshilfe">🤝 Hilfe</button>
<button class="chip" data-filter="Veranstaltungen">🎉 Veranstaltungen</button>
<button class="chip" data-filter="Immobilien">🏡 Immobilien</button>
<button class="chip" data-filter="Sonstiges">📌 Sonstiges</button>
</div>
</section>
<!-- Neueste Einträge -->
<section id="neu" class="container" style="padding-block:8px 48px;">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px;">
<h2 style="font-size:20px;font-weight:600;">Neueste Einträge</h2>
<button id="loadMore" class="btn ghost" style="font-size:14px;">Mehr anzeigen</button>
</div>
<div id="cards" class="results"></div>
</section>
<!-- CTA -->
<section class="cta">
<div class="container" style="padding-block:40px;display:grid;gap:16px;grid-template-columns: 1fr auto; align-items:center;">
<div>
<h3 style="font-size:24px;font-weight:600;">Mach dein Anliegen sichtbar</h3>
<p class="text-muted mt-1">Erstelle jetzt kostenlos einen Aushang. Optional mit Bild läuft automatisch nach 30 Tagen aus.</p>
</div>
<button id="openCreateCta" class="btn">Jetzt Aushang erstellen</button>
</div>
</section>
<!-- So funktionierts -->
<section id="sofunktionierts" class="container" style="padding-block:40px;">
<h2 style="font-size:20px;font-weight:600;" class="mb-4">So funktionierts</h2>
<div class="grid grid-3">
<div class="surface border rounded p-4">
<div style="font-size:24px;">1️⃣</div>
<h3 class="mt-2" style="font-weight:600;">Inserat erstellen</h3>
<p class="text-muted mt-1" style="font-size:14px;">Titel, Beschreibung, Kategorie, Ort optional ein Bild. Fertig.</p>
</div>
<div class="surface border rounded p-4">
<div style="font-size:24px;">2️⃣</div>
<h3 class="mt-2" style="font-weight:600;">Lokal sichtbar</h3>
<p class="text-muted mt-1" style="font-size:14px;">Dein Aushang erscheint bei Leuten in der Nähe ohne AlgorithmusChaos.</p>
</div>
<div class="surface border rounded p-4">
<div style="font-size:24px;">3️⃣</div>
<h3 class="mt-2" style="font-weight:600;">Kontakt aufnehmen</h3>
<p class="text-muted mt-1" style="font-size:14px;">Direkt per EMail oder Telefon ohne Zwischenhändler.</p>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="container" style="padding-block:24px;display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;">
<p>© 2025 schwarzesbrett.online</p>
<nav style="display:flex;gap:16px;">
<a href="#">Impressum</a>
<a href="#">Datenschutz</a>
<a href="#">Kontakt</a>
</nav>
</div>
</footer>
<!-- Modal: Inserat erstellen -->
<div id="createModal" class="modal" role="dialog" aria-modal="true" aria-labelledby="createTitle">
<div class="panel">
<div class="head">
<h3 id="createTitle" style="font-weight:600;">Inserat erstellen</h3>
<button id="closeCreate" class="btn ghost" aria-label="Schließen"></button>
</div>
<form id="createForm" class="grid">
<div class="form-row">
<div>
<label class="label" for="title">Titel</label>
<input required id="title" name="title" class="input mt-1" />
</div>
<div>
<label class="label" for="category">Kategorie</label>
<select required id="category" name="category" class="select mt-1">
<option value="Flohmarkt">Flohmarkt</option>
<option value="Jobs">Jobs</option>
<option value="Nachbarschaftshilfe">Nachbarschaftshilfe</option>
<option value="Veranstaltungen">Veranstaltungen</option>
<option value="Immobilien">Immobilien</option>
<option value="Sonstiges">Sonstiges</option>
</select>
</div>
</div>
<div>
<label class="label" for="desc">Beschreibung</label>
<textarea required id="desc" name="desc" class="textarea mt-1" rows="4"></textarea>
</div>
<div class="form-row" style="grid-template-columns: 120px 1fr 1fr;">
<div>
<label class="label" for="zip2">PLZ</label>
<input required id="zip2" name="zip" class="input mt-1" maxlength="5" inputmode="numeric" />
</div>
<div>
<label class="label" for="city">Ort</label>
<input required id="city" name="city" class="input mt-1" />
</div>
<div>
<label class="label" for="contact">Kontakt (EMail o. Tel.)</label>
<input required id="contact" name="contact" class="input mt-1" />
</div>
</div>
<div>
<label class="label" for="img">BildURL (optional)</label>
<input id="img" name="img" class="input mt-1" placeholder="https://…" />
</div>
<div style="display:flex;justify-content:flex-end;gap:12px;">
<button type="button" id="cancelCreate" class="btn outline">Abbrechen</button>
<button class="btn" type="submit">Veröffentlichen</button>
</div>
</form>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast">Aushang veröffentlicht</div>
<script>
// Demo-State (ohne Backend) kann später durch API ersetzt werden
const seed = [
{id:1, title:"Mountainbike zu verkaufen", desc:"Gut gepflegtes 26'' MTB, neue Bremsen, Probefahrt möglich.", category:"Flohmarkt", zip:"10115", city:"Berlin", contact:"maria@example.com", img:"https://images.unsplash.com/photo-1520975693416-35e09df6f242?q=80&w=1200&auto=format&fit=crop", date:"2025-08-10"},
{id:2, title:"Babysitter gesucht", desc:"Suche zuverlässige Betreuung für 2 Abende/Woche.", category:"Jobs", zip:"50667", city:"Köln", contact:"0176 123456", img:"", date:"2025-08-12"},
{id:3, title:"Bohrmaschine leihen", desc:"Brauche am Wochenende eine Bohrmaschine Gegen Kuchen 😀", category:"Nachbarschaftshilfe", zip:"22767", city:"Hamburg", contact:"timo@example.com", img:"", date:"2025-08-09"}
];
const state = { ads: seed, perPage: 6, currentCategory: "", search: "", zip: "" };
const $ = (s, r=document)=> r.querySelector(s);
const $$ = (s, r=document)=> Array.from(r.querySelectorAll(s));
const el = {
cards: $('#cards'), q: $('#q'), zip: $('#zip'), loadMore: $('#loadMore'),
chips: $$('.chip'), noteCats: $$('[data-cat]'),
createModal: $('#createModal'), openCreate: $('#openCreate'), openCreateMobile: $('#openCreateMobile'), openCreateHero: $('#openCreateHero'), openCreateCta: $('#openCreateCta'),
closeCreate: $('#closeCreate'), cancelCreate: $('#cancelCreate'), createForm: $('#createForm'),
openMenu: $('#openMenu'), mobileMenu: $('#mobileMenu'), detectLocation: $('#detectLocation'),
toast: $('#toast')
};
function fmtDate(d){ const date = new Date(d); return date.toLocaleDateString('de-DE'); }
function expiry(d){ const dt = new Date(d); dt.setDate(dt.getDate()+30); return dt.toLocaleDateString('de-DE'); }
function card(item){
const img = item.img && item.img.startsWith('http') ? `<img class="img" src="${item.img}" alt="Bild zu ${item.title}" loading="lazy">` : '';
return `<article class="card">
${img}
<div class="body">
<div class="row"><span class="badge">${item.category}</span><span>${fmtDate(item.date)}</span></div>
<h3>${item.title}</h3>
<p class="clamp-3 mt-1">${item.desc}</p>
<div class="row mt-2" style="font-size:14px;">
<div>📍 ${item.zip} ${item.city}</div>
<div>läuft ab: ${expiry(item.date)}</div>
</div>
<div class="mt-2" style="display:flex;gap:12px;align-items:center;">
<a class="btn outline" href="mailto:${encodeURIComponent(item.contact)}">Kontakt</a>
<button class="btn ghost" style="font-size:14px;">Merken</button>
</div>
</div>
</article>`;
}
function render(append=false){
const start = append ? el.cards.children.length : 0;
if(!append) el.cards.innerHTML = '';
const filtered = state.ads.filter(a=>{
const byCat = state.currentCategory ? a.category===state.currentCategory : true;
const byQ = state.search ? (a.title+" "+a.desc).toLowerCase().includes(state.search.toLowerCase()) : true;
const byZip = state.zip ? a.zip.startsWith(state.zip) : true;
return byCat && byQ && byZip;
});
const slice = filtered.slice(start, start + state.perPage);
slice.forEach(item=> el.cards.insertAdjacentHTML('beforeend', card(item)) );
$('#loadMore').classList.toggle('hide', (start + state.perPage) >= filtered.length);
}
// Events
$('#searchForm').addEventListener('submit', e=>{ e.preventDefault(); state.search = el.q.value.trim(); state.zip = el.zip.value.trim(); render(false); });
$('#loadMore').addEventListener('click', ()=> render(true));
el.chips.forEach(c=> c.addEventListener('click', ()=>{ state.currentCategory = c.dataset.filter; el.chips.forEach(x=>x.classList.toggle('active', x===c)); render(false); }));
el.noteCats.forEach(n=> n.addEventListener('click', ()=>{ state.currentCategory = n.dataset.cat; document.getElementById('kategorien').scrollIntoView({behavior:'smooth'}); el.chips.forEach(x=> x.classList.toggle('active', x.dataset.filter===state.currentCategory)); render(false); }));
// Modal
function openCreate(){ el.createModal.classList.add('open'); $('#title').focus(); }
function closeCreate(){ el.createModal.classList.remove('open'); }
[el.openCreate, el.openCreateMobile, el.openCreateHero, el.openCreateCta].forEach(b=> b&&b.addEventListener('click', openCreate));
el.closeCreate.addEventListener('click', closeCreate);
el.cancelCreate.addEventListener('click', closeCreate);
el.createModal.addEventListener('click', (e)=>{ if(e.target===el.createModal) closeCreate(); });
el.createForm.addEventListener('submit', e=>{
e.preventDefault();
const fd = new FormData(e.target); const ad = Object.fromEntries(fd.entries());
const id = Math.max(0, ...state.ads.map(a=>a.id))+1; const today = new Date();
state.ads.unshift({ id, title: ad.title.trim(), desc: ad.desc.trim(), category: ad.category, zip: ad.zip.trim(), city: ad.city.trim(), contact: ad.contact.trim(), img: ad.img?.trim()||"", date: today.toISOString().slice(0,10) });
closeCreate(); e.target.reset(); state.currentCategory = ""; render(false);
el.toast.textContent = 'Aushang veröffentlicht'; el.toast.classList.add('show'); setTimeout(()=> el.toast.classList.remove('show'), 2200);
window.scrollTo({top:0, behavior:'smooth'});
});
// Mobile Menu
el.openMenu.addEventListener('click', ()=> el.mobileMenu.classList.toggle('open'));
// Geolocation (Demo)
$('#detectLocation').addEventListener('click', ()=>{
if(!navigator.geolocation) return alert('Geolocation wird nicht unterstützt.');
navigator.geolocation.getCurrentPosition(()=>{ document.getElementById('heroOrt').textContent = 'deine Nähe'; alert('Für genaue Ergebnisse gib deine PLZ ein.'); }, ()=> alert('Standort konnte nicht ermittelt werden.'));
});
// Initial
render(false);
</script>
</body>
</html>
// Landing-Page über Partials laden
tpl('home', 'landing', 'main');

142
schema.sql Normal file
View File

@@ -0,0 +1,142 @@
-- Papa-Kind-Treff Basis-Schema (MySQL 8)
-- Hinweise:
-- - Passwörter mit Argon2id hashen.
-- - Sensible Felder werden app-seitig mit libsodium (XChaCha20-Poly1305) verschlüsselt
-- und als base64 in VARBINARY-Spalten abgelegt (nonce + cipher).
-- - share_level steuert Papa-Infos (basic, papa, papa_contact).
-- - children_visibility steuert Kinder-Infos separat (hidden, age_only, details).
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
status ENUM('active','pending','blocked') DEFAULT 'active',
email_verified_at DATETIME NULL,
last_login_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE user_profiles (
user_id BIGINT UNSIGNED PRIMARY KEY,
display_name VARCHAR(120) NOT NULL,
share_level ENUM('basic','papa','papa_contact') NOT NULL DEFAULT 'basic',
children_visibility ENUM('hidden','age_only','details') NOT NULL DEFAULT 'hidden',
zip CHAR(5) NULL,
city VARCHAR(120) NULL,
region VARCHAR(120) NULL,
lat DECIMAL(10,7) NULL,
lng DECIMAL(10,7) NULL,
contact_phone VARBINARY(512) NULL,
contact_email VARBINARY(512) NULL,
profession VARBINARY(512) NULL,
languages VARBINARY(1024) NULL, -- JSON verschlüsselt
about VARBINARY(2048) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_profile_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_profile_city (city),
INDEX idx_profile_region (region),
INDEX idx_profile_latlng (lat,lng)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE children (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
gender ENUM('male','female','diverse','unknown') NOT NULL DEFAULT 'unknown',
birthdate DATE NULL,
age_years TINYINT UNSIGNED NULL,
encrypted_first_name VARBINARY(512) NOT NULL,
note VARBINARY(1024) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_child_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_child_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
created_by BIGINT UNSIGNED NOT NULL,
title VARCHAR(200) NOT NULL,
teaser_public VARCHAR(280) NOT NULL,
description TEXT NOT NULL,
location_label VARCHAR(180) NULL,
zip CHAR(5) NULL,
city VARCHAR(120) NULL,
region VARCHAR(120) NULL,
lat DECIMAL(10,7) NULL,
lng DECIMAL(10,7) NULL,
starts_at DATETIME NOT NULL,
ends_at DATETIME NULL,
max_participants SMALLINT UNSIGNED NULL,
allow_kids TINYINT(1) NOT NULL DEFAULT 1,
min_child_age TINYINT UNSIGNED NULL,
max_child_age TINYINT UNSIGNED NULL,
visibility ENUM('public','members') NOT NULL DEFAULT 'public',
status ENUM('draft','published','cancelled') NOT NULL DEFAULT 'published',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_event_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_event_city (city),
INDEX idx_event_region (region),
INDEX idx_event_time (starts_at),
INDEX idx_event_latlng (lat,lng)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE event_participants (
event_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
status ENUM('going','interested','waitlist','cancelled') NOT NULL DEFAULT 'going',
child_count TINYINT UNSIGNED NULL,
note VARBINARY(1024) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (event_id, user_id),
CONSTRAINT fk_ep_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
CONSTRAINT fk_ep_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Session-Handling (neutral, keine sensiblen Inhalte)
CREATE TABLE sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE, -- SHA-256 Hash des Session-Tokens
ip VARCHAR(45) NULL,
user_agent VARCHAR(255) NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_session_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_session_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Kurzlebige Tokens (Passwort-Reset, Magic-Login, E-Mail-Verify)
CREATE TABLE user_tokens (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
type ENUM('reset','verify','magic_login') NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
used_at DATETIME NULL,
ip VARCHAR(45) NULL,
user_agent VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_ut_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_ut_expires (expires_at),
INDEX idx_ut_type (type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Audit-Log für wichtige Aktionen
CREATE TABLE audit_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NULL, -- NULL bei Gast-Ereignissen
action VARCHAR(100) NOT NULL, -- z.B. login, profile.update, event.create
target_type VARCHAR(50) NULL, -- z.B. user, event, child
target_id BIGINT UNSIGNED NULL,
metadata JSON NULL, -- nicht sensible Zusatzinfos
ip VARCHAR(45) NULL,
user_agent VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_user (user_id),
INDEX idx_audit_action (action),
INDEX idx_audit_target (target_type, target_id),
INDEX idx_audit_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -13,11 +13,11 @@ final class I18n
// Minimal example translations (normally load JSON/PHP arrays from disk)
$this->fallback = [
'common' => [
'title' => 'Mini Example Landingpage',
'intro' => 'This is a tiny project showing a clean bootstrap.',
'title' => 'Papa-Kind-Treff',
'intro' => 'Väter vernetzen sich für Treffen mit und ohne Kinder.',
],
'cta' => [
'primary' => 'Continue',
'primary' => 'Weiter',
],
];