-
-
Runtime
-
Current URL: = htmlspecialchars($app->request()->currentUrl(), ENT_QUOTES) ?>
-
Client-ID: = htmlspecialchars($GLOBALS['client_id'] ?? '', ENT_QUOTES) ?>
+
+
+
+
Gemeinsam stark
+
Treffen für Väter – mit und ohne Kinder. Lokal, sicher, verschlüsselt.
+
Finde andere Väter in deiner Nähe, plane Events oder tritt bestehenden Treffen bei. Daten bleiben geschützt, du entscheidest die Sichtbarkeit.
+
+ Kostenlos registrieren
+ Events in deiner Nähe
+
+
+
+
+
Schnellsuche
+
Events in deiner Region
+
+ PLZ oder Ort
+
+
+
+ Thema
+
+ Alle
+ Outdoor / Spielplatz
+ Kaffee & Austausch
+ Sport
+ Workshop
+
+
+
+ Alter der Kinder
+
+ Alle
+ 0-2 Jahre
+ 3-6 Jahre
+ 7-12 Jahre
+
+
+
Events anzeigen
+
Standort automatisch ermitteln
+
Geodaten werden nur zur Anzeige verwendet.
+
-
-
Actions
-
-
Flash uses SessionManager, no direct globals.
+
+
+
+
+
+
Termine entdecken
+
Upcoming Events in deiner Nähe
+
Gäste sehen nur Basisinfos. Als Mitglied siehst du vollständige Details, kannst zusagen und neue Treffen anlegen.
+
+
+ Alle
+ Outdoor
+ Kaffee
+ Sport
+ Workshops
+
-
-
+
+
+
+
+
Nur für eingeloggte Mitglieder
+
Volle Beschreibung, Kontakt, Kinder-Infos und Anmeldeoptionen.
+
+
Jetzt anmelden
+
+
+
+
+
+
+
+
Profil & Datenschutz
+
Du entscheidest, was sichtbar ist.
+
Papa-Daten und Kinder-Daten sind getrennt. Sichtbarkeiten steuerst du per Schalter, sensible Felder werden verschlüsselt abgelegt.
+
+
+
Papa-Sichtbarkeit
+
+ basic: Name, Region, Kinderanzahl
+ papa: + Beruf, Sprachen, About
+ papa_contact: + Telefon/Email
+
+
+
+
Kinder separat
+
+ hidden: keine Details
+ age_only: nur Alter/Altersgruppe
+ details: Vorname, Geschlecht, Alter
+
+
+
+
+ Verschlüsselung mit libsodium
+ Nonce pro Feld
+ Key aus ENV
+
+
+
+
Sicherheit
+
Wie wird gespeichert?
+
+ Symmetrische Verschlüsselung (XChaCha20-Poly1305)
+ Key nur im Backend (ENV), nicht in der DB
+ Felder: Kontakt, Beruf, Sprachen, About, Kinder
+ Events unterscheiden Public vs. Members-Details
+
+
Bei Datenabzug bleiben Felder unlesbar. Zugriff nur mit Key.
+
+
+
+
+
+
+
+
Ablauf
+
So funktioniert’s
+
+
+
+
+
1
+
Profil anlegen
+
Papa-Daten ausfüllen, Kinder optional. Sichtbarkeit pro Bereich einstellen.
+
+
+
2
+
Events finden
+
Suche nach Thema, Alter oder Region. Gäste sehen nur Basisinfos.
+
+
+
3
+
Treffen planen
+
Als Mitglied neue Events anlegen, andere einladen, Teilnahme verwalten.
+
+
+
+
+
+
+
+
Noch Fragen?
+
FAQ
+
+
+ Wer sieht meine Daten?
+ 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.
+
+
+ Wie funktionieren Events?
+ Eingeloggt kannst du Events erstellen. Andere Mitglieder melden sich an. Gäste sehen nur Teaser, Ort grob und Datum.
+
+
+ Welche Daten sind verschlüsselt?
+ Kontakt, Beruf, Sprachen, About, Kinder-Vornamen/Geschlecht/Notizen werden app-seitig verschlüsselt gespeichert.
+
+
+
+
+
Bereit?
+
Jetzt loslegen
+
Registriere dich kostenlos, lege dein Profil an und vernetze dich mit anderen Vätern.
+
+ Registrieren
+ Login
+
+
+
+
+
diff --git a/partials/structure/layout_start.php b/partials/structure/layout_start.php
index 7d419fc..abcece6 100644
--- a/partials/structure/layout_start.php
+++ b/partials/structure/layout_start.php
@@ -1,22 +1,26 @@
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 = '';
+}
?>
-
+
= htmlspecialchars(t('common.title'), ENT_QUOTES) ?>
+
-
-
+
+
diff --git a/partials/structure/nav.php b/partials/structure/nav.php
new file mode 100644
index 0000000..e857454
--- /dev/null
+++ b/partials/structure/nav.php
@@ -0,0 +1,44 @@
+
+
diff --git a/public/assets/css/app.css b/public/assets/css/app.css
index 1867f66..dbba863 100644
--- a/public/assets/css/app.css
+++ b/public/assets/css/app.css
@@ -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; }
+}
diff --git a/public/assets/css/styles.css b/public/assets/css/styles.css
index 8fe6cb7..4b2dd98 100755
--- a/public/assets/css/styles.css
+++ b/public/assets/css/styles.css
@@ -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; }
diff --git a/public/assets/js/app.js b/public/assets/js/app.js
index bbe8027..56219b3 100644
--- a/public/assets/js/app.js
+++ b/public/assets/js/app.js
@@ -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) => `
${label} `;
+
+ const renderCard = (item) => {
+ const guest = !isLoggedIn;
+ const access = item.visibility === 'members' && guest ? '
Nur für Mitglieder
' : '';
+ const desc = guest ? `
Melde dich an, um die volle Beschreibung zu sehen.
` : `
${item.description}
`;
+ const contact = !guest ? `
Kontakt: ${item.contact}
` : '';
+ 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 `
+
+
+ ${access}
+
+ ${fmtDate(item.startsAt)}
+ 📍 ${item.region || item.city}
+ ${item.visibility === 'public' ? 'Öffentlich' : 'Mitglieder'}
+
+
${item.title}
+
${guest ? item.teaser : item.description.slice(0, 140) + '...'}
+ ${guest ? '' : desc}
+
${tags}
+ ${contact}
+
+ Details
+ ${!guest ? 'Teilnehmen ' : 'Anmelden '}
+
+
+ `;
+ };
+
+ 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('') || '
Keine Events gefunden.
';
+ };
+
+ 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();
+});
diff --git a/public/page/index.php b/public/page/index.php
old mode 100755
new mode 100644
index 3c934f3..d035499
--- a/public/page/index.php
+++ b/public/page/index.php
@@ -1,310 +1,5 @@
-
-
-
-
-
-
schwarzesbrett.online – Digitales Schwarzes Brett
-
-
-
-
-
-
-
+
-
-
-
-
Dein digitales Schwarzes Brett für deine Nachbarschaft
-
Inserate, Nachbarschaftshilfe & Veranstaltungen – lokal, übersichtlich und frei von Spam.
-
-
- PLZ automatisch erkennen
- Jetzt kostenlos Aushang erstellen
-
-
-
-
-
-
- 🛒 Flohmarkt
- Verkaufen & Finden
-
-
- 💼 Jobs
- Mini- & Nebenjobs
-
-
- 🤝 Hilfe
- Leihen, Mitnehmen
-
-
- 🎉 Veranstaltungen
- Heute & bald
-
-
- 🏡 Immobilien
- Miete & WG
-
-
- 📌 Sonstiges
- Alles andere
-
-
-
-
-
-
-
-
-
- Entdecke Kategorien
-
- Alle
- 🛒 Flohmarkt
- 💼 Jobs
- 🤝 Hilfe
- 🎉 Veranstaltungen
- 🏡 Immobilien
- 📌 Sonstiges
-
-
-
-
-
-
-
Neueste Einträge
- Mehr anzeigen
-
-
-
-
-
-
-
-
-
Mach dein Anliegen sichtbar
-
Erstelle jetzt kostenlos einen Aushang. Optional mit Bild – läuft automatisch nach 30 Tagen aus.
-
-
Jetzt Aushang erstellen
-
-
-
-
-
- So funktioniert’s
-
-
-
1️⃣
-
Inserat erstellen
-
Titel, Beschreibung, Kategorie, Ort – optional ein Bild. Fertig.
-
-
-
2️⃣
-
Lokal sichtbar
-
Dein Aushang erscheint bei Leuten in der Nähe – ohne Algorithmus‑Chaos.
-
-
-
3️⃣
-
Kontakt aufnehmen
-
Direkt per E‑Mail oder Telefon – ohne Zwischenhändler.
-
-
-
-
-
-
-
-
-
-
-
-
Inserat erstellen
- ✕
-
-
-
-
-
-
-
Aushang veröffentlicht
-
-
-
-
+// Landing-Page über Partials laden
+tpl('home', 'landing', 'main');
diff --git a/schema.sql b/schema.sql
new file mode 100644
index 0000000..57f01ae
--- /dev/null
+++ b/schema.sql
@@ -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;
diff --git a/src/App/I18n.php b/src/App/I18n.php
index f03b1f9..44a9b60 100644
--- a/src/App/I18n.php
+++ b/src/App/I18n.php
@@ -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',
],
];