Compare commits

23 Commits

Author SHA1 Message Date
eb16827fdf community und dsgvo
All checks were successful
Deploy / deploy (push) Successful in 51s
2026-07-22 21:29:50 +02:00
751b6996c4 adasd 2026-07-22 21:15:44 +02:00
1176b98522 community
All checks were successful
Deploy / deploy (push) Successful in 49s
2026-07-22 20:59:57 +02:00
60998777a4 community update
All checks were successful
Deploy / deploy (push) Successful in 52s
2026-07-22 20:55:13 +02:00
257f9af358 ycyxc
All checks were successful
Deploy / deploy (push) Successful in 51s
2026-07-21 22:43:41 +02:00
b9026c7808 asddad
All checks were successful
Deploy / deploy (push) Successful in 49s
2026-07-19 22:08:55 +02:00
b9673814b5 hhhh
All checks were successful
Deploy / deploy (push) Successful in 48s
2026-07-19 03:08:35 +02:00
3ff5767900 community
All checks were successful
Deploy / deploy (push) Successful in 47s
2026-07-18 01:53:44 +02:00
57e197755e adfs
All checks were successful
Deploy / deploy (push) Successful in 47s
2026-07-18 01:04:18 +02:00
e39a483aff community
All checks were successful
Deploy / deploy (push) Successful in 44s
2026-07-17 03:08:28 +02:00
d682d1dd2b adasd
All checks were successful
Deploy / deploy (push) Successful in 43s
2026-07-17 02:50:52 +02:00
f614de8758 asdasd
All checks were successful
Deploy / deploy (push) Successful in 45s
2026-07-17 02:47:05 +02:00
9e4570da45 ycy
All checks were successful
Deploy / deploy (push) Successful in 44s
2026-07-17 02:37:42 +02:00
e23b6b9813 adasd
All checks were successful
Deploy / deploy (push) Successful in 44s
2026-07-17 02:32:05 +02:00
28b7a9b4aa adsad
All checks were successful
Deploy / deploy (push) Successful in 45s
2026-07-17 02:13:16 +02:00
e010a1466c adasd
All checks were successful
Deploy / deploy (push) Successful in 45s
2026-07-17 01:35:10 +02:00
6fae7246ff sdasd
All checks were successful
Deploy / deploy (push) Successful in 44s
2026-07-17 01:13:47 +02:00
48ab4ab922 search block
All checks were successful
Deploy / deploy (push) Successful in 44s
2026-07-17 00:56:26 +02:00
a2a8d9c7ba ydfdsf
All checks were successful
Deploy / deploy (push) Successful in 44s
2026-07-17 00:52:07 +02:00
fb7c807f2a ssd
All checks were successful
Deploy / deploy (push) Successful in 43s
2026-07-17 00:43:58 +02:00
a586d7cf0e sdfsdf
All checks were successful
Deploy / deploy (push) Successful in 43s
2026-07-17 00:33:57 +02:00
429258156a adasd
All checks were successful
Deploy / deploy (push) Successful in 46s
2026-07-17 00:19:05 +02:00
dca3ce93e6 refresh
All checks were successful
Deploy / deploy (push) Successful in 49s
2026-07-16 01:21:29 +02:00
92 changed files with 3426 additions and 303 deletions

115
.gitea/workflows/deploy.yml Normal file
View File

@@ -0,0 +1,115 @@
name: Deploy
on:
push:
branches:
- main
- develop
env:
BASE_DIRS: "src public api partials tools"
CONFIG_BASE_DIR: "config"
jobs:
deploy:
runs-on: private-server
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install lftp
shell: sh
run: |
if command -v lftp >/dev/null 2>&1; then
echo "✅ lftp bereits installiert"
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache lftp ca-certificates
elif command -v apt-get >/dev/null 2>&1; then
apt-get update
apt-get install -y lftp ca-certificates
else
echo "❌ Kein unterstützter Paketmanager gefunden"
exit 1
fi
- name: Set environment
run: |
if [ "${{ gitea.ref_name }}" = "main" ]; then
echo "TARGET_PATH=${{ vars.FTP_PATH_PROD }}" >> "$GITHUB_ENV"
echo "CONFIG_ENV_DIR=config/prod" >> "$GITHUB_ENV"
elif [ "${{ gitea.ref_name }}" = "develop" ]; then
echo "TARGET_PATH=${{ vars.FTP_PATH_STAGING }}" >> "$GITHUB_ENV"
echo "CONFIG_ENV_DIR=config/staging" >> "$GITHUB_ENV"
else
echo "Unsupported branch"
exit 1
fi
- name: Debug workspace
run: |
echo "📂 CI Workspace:"
pwd
ls -la
- name: Deploy via FTPS
run: |
set -e
echo "🚀 Deploy to ${TARGET_PATH}"
VALID_DIRS=""
for d in $BASE_DIRS; do
if [ -d "$d" ]; then
VALID_DIRS="$VALID_DIRS $d"
else
echo "⚠️ Überspringe fehlendes Verzeichnis: $d"
fi
done
if [ -z "$VALID_DIRS" ]; then
echo "❌ Kein deploybares Verzeichnis gefunden."
exit 1
fi
for d in $VALID_DIRS; do
echo "🔁 ${d}/ → ${TARGET_PATH}${d}/"
lftp -u "${{ secrets.FTP_USER }}","${{ secrets.FTP_PASSWORD }}" "${{ vars.FTP_HOST }}" -e "
set ftp:ssl-force true;
set ftp:passive-mode true;
set ftp:ssl-protect-data true;
set ssl:verify-certificate no;
mirror -R --delete --exclude .gitkeep ${d}/ ${TARGET_PATH}${d}/;
bye
" || exit 1
done
if [ -d "$CONFIG_BASE_DIR" ] && [ -d "$CONFIG_ENV_DIR" ]; then
echo "🧩 Baue gemischtes Config-Verzeichnis"
rm -rf .ci_config_deploy
mkdir -p .ci_config_deploy
for f in ${CONFIG_BASE_DIR}/*.php; do
[ -f "$f" ] && cp "$f" .ci_config_deploy/
done
cp -R ${CONFIG_ENV_DIR}/. .ci_config_deploy/
echo "🔁 config → ${TARGET_PATH}${CONFIG_BASE_DIR}/"
lftp -u "${{ secrets.FTP_USER }}","${{ secrets.FTP_PASSWORD }}" "${{ vars.FTP_HOST }}" -e "
set ftp:ssl-force true;
set ftp:passive-mode true;
set ftp:ssl-protect-data true;
set ssl:verify-certificate no;
lcd .ci_config_deploy;
mirror -R --delete --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
bye
" || exit 1
else
echo "⚠️ Config-Deploy übersprungen: ${CONFIG_BASE_DIR} oder ${CONFIG_ENV_DIR} fehlt"
fi
echo "✅ Deploy abgeschlossen"

0
.gitlab-ci.yml Normal file → Executable file
View File

73
.projektstructure.txt Executable file
View File

@@ -0,0 +1,73 @@
Anweisung: Projektstruktur (Basis-Template) wie in „papa-kind-treff“
Ziel
- Erstelle ein neues Projekt bzw. aktualisiere das aktuelle mit exakt der gleichen Grundstruktur wie in „papa-kind-treff“.
- Fokus auf Hauptordner und deren Zweck; keine projektspezifischen Sonderordner (z. B. Community) anlegen.
- Inhalte können minimal sein, aber alle Pfade müssen existieren.
- In Ordnern, die noch keine Dateien beinhalten, muss eine leere Datei mit dem Namen .gitkeep erstellt werden.
- Bestehende Dateien nicht überschreiben; wenn nötig, Inhalte behutsam an die neue Logik anpassen.
Verzeichnisstruktur (Pflichtordner)
- api/
- config/
- debug/
- partials/
- public/
- src/
- tools/
- README.md
- schema.sql
- .gitlab-ci.yml
Details je Ordner
config/
- Enthält alle Konfigurationen.
- Muss Subordner für Umgebungen haben: z. B. prod/ und staging/.
- In den Umgebungsordnern liegen Basis-Konfigurationen (z. B. db.php, settings.php, emailtemplates.php, domaindata.php).
- Hinweis: Die Umgebungs-Subordner werden beim Deployment in den root-Config kopiert; daher ist hier keine weitere Unterscheidung nötig.
- Falls Dateien fehlen, lege sie mit minimalem Basisinhalt an (z. B. PHP-Array/Kommentar), ohne vorhandene Inhalte zu überschreiben.
api/
- Schnittstellen/Endpunkte.
- Kann zunächst leer bleiben; dann .gitkeep anlegen.
debug/
- Debug-Hilfen, Logs oder Debug-Skripte.
- Wenn leer: .gitkeep.
partials/
- Nur die Unterscheidung in:
- landing/
- structure/
- landing/: Platz für seitenbezogene Teilausschnitte.
- structure/: Layout-Grundbausteine (z. B. layout_start.php, layout_end.php, nav.php, matomo.php) als Beispieldaten.
public/
- Webroot.
- Muss assets/ enthalten mit Unterordnern: bilder/, fonts/, js/, css/ (Dateien optional).
- Muss index.php enthalten mit Basis-Logic (z. B. Entry-Point/Router/Bootstrap).
src/
- Backend/Business-Logik und Kernklassen.
- Enthält App- oder Domain-Code (z. B. Auth, Database, Mailer, Config, Request, Assets usw.).
- Lege eine kurze Beschreibung an, wofür src gedacht ist (Kommentar oder README im Ordner).
tools/
- Entwicklungs-/Wartungs-Tools, Skripte oder Utilities.
- Inhaltlich analog zu src gedacht, aber für interne Werkzeuge.
Datei-Inhalte (minimal, aber vorhanden)
- .gitkeep: leer.
- README.md, schema.sql, .gitlab-ci.yml: leer oder mit kurzem Platzhaltertext.
- PHP-Dateien: leer oder mit kurzem Kommentar, z. B. "<?php // TODO".
- .htaccess (falls genutzt): leer oder minimaler Platzhalter.
Zusätzliche Regeln
- Keine projektspezifischen Sonderordner anlegen.
- Pfade und Dateinamen exakt, Groß-/Kleinschreibung beachten.
- Struktur ist wichtiger als Inhalt.
Ausgabeformat
- Erzeuge die Ordner und Dateien exakt wie oben.
- Liefere optional eine kurze Zusammenfassung der angelegten/angepassten Struktur.

0
README.md Normal file → Executable file
View File

0
api/.gitkeep Normal file → Executable file
View File

0
config/.gitkeep Normal file → Executable file
View File

0
config/community.php Normal file → Executable file
View File

119
config/community_forums.php Normal file
View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
return [
[
'slug' => 'allgemein',
'title' => 'Allgemein',
'boards' => [
[
'slug' => 'neu-hier',
'title' => 'Neu hier?',
'description' => 'Vorstellen, ankommen und die ersten Fragen rund um die Community stellen.',
'icon' => '👋',
],
[
'slug' => 'regeln-und-hinweise',
'title' => 'Regeln und Hinweise',
'description' => 'Wichtige Informationen zum Miteinander, zur Netiquette und zu Abläufen in der Community.',
'icon' => '📌',
],
[
'slug' => 'fragen-zur-plattform',
'title' => 'Fragen zur Plattform',
'description' => 'Fragen, Probleme oder Ideen zur Nutzung von Papa-Kind-Treff direkt hier sammeln.',
'icon' => '❓',
],
],
],
[
'slug' => 'alltag-mit-kindern',
'title' => 'Alltag mit Kindern',
'boards' => [
[
'slug' => 'kinderalltag',
'title' => 'Kinderalltag',
'description' => 'Erfahrungen, Fragen und Gedanken rund um das tägliche Leben mit Kindern.',
'icon' => '🧸',
],
[
'slug' => 'kita-schule-routinen',
'title' => 'Kita, Schule, Routinen',
'description' => 'Austausch zu Alltag, Betreuung, Übergängen und wiederkehrenden Herausforderungen.',
'icon' => '🎒',
],
[
'slug' => 'sonstiges',
'title' => 'Sonstiges',
'description' => 'Alles, was thematisch dazugehört, aber in keinen der anderen Bereiche sauber hineinpasst.',
'icon' => '🧩',
],
],
],
[
'slug' => 'treffen-und-unternehmungen',
'title' => 'Treffen & Unternehmungen',
'boards' => [
[
'slug' => 'spielplaetze-und-ausfluege',
'title' => 'Spielplätze und Ausflüge',
'description' => 'Empfehlungen, Erfahrungen und konkrete Tipps für gemeinsame Unternehmungen.',
'icon' => '🛝',
],
[
'slug' => 'wochenendideen',
'title' => 'Wochenendideen',
'description' => 'Was sich für freie Tage, spontane Treffen und kleine Abenteuer anbietet.',
'icon' => '🗓️',
],
[
'slug' => 'urlaub-mit-kindern',
'title' => 'Urlaub mit Kindern',
'description' => 'Aktivitäten, Kontakte und Ideen für entspannte Tage unterwegs oder im Urlaub.',
'icon' => '🏖️',
],
],
],
[
'slug' => 'austausch-unter-vaetern',
'title' => 'Austausch unter Vätern',
'boards' => [
[
'slug' => 'vaterrolle',
'title' => 'Vaterrolle',
'description' => 'Wie sich Vatersein anfühlt, verändert und im Alltag erlebt wird.',
'icon' => '🧑‍🧒',
],
[
'slug' => 'beziehungen-und-familienleben',
'title' => 'Beziehungen und Familienleben',
'description' => 'Partnerschaft, Familie, Belastungen und schöne Momente im gemeinsamen Leben.',
'icon' => '🏡',
],
[
'slug' => 'unsicherheiten-fragen-erfahrungen',
'title' => 'Unsicherheiten, Fragen, Erfahrungen',
'description' => 'Raum für ehrliche Fragen, Zweifel und Erfahrungen ohne große Hürden.',
'icon' => '💬',
],
],
],
[
'slug' => 'feedback-zur-plattform',
'title' => 'Feedback zur Plattform',
'boards' => [
[
'slug' => 'ideen-und-vorschlaege',
'title' => 'Ideen & Vorschläge',
'description' => 'Neue Funktionen, Verbesserungen und Wünsche rund um Papa-Kind-Treff.',
'icon' => '💡',
],
[
'slug' => 'fehler-und-probleme',
'title' => 'Fehler und Probleme',
'description' => 'Wenn etwas nicht funktioniert oder technisch hakt, gehört es hier hin.',
'icon' => '🛠️',
],
],
],
];

0
config/config.php Normal file → Executable file
View File

0
config/fileload.php Normal file → Executable file
View File

0
config/prod/.gitkeep Normal file → Executable file
View File

0
config/prod/db.php Normal file → Executable file
View File

0
config/prod/domaindata.php Normal file → Executable file
View File

0
config/prod/emailtemplates.php Normal file → Executable file
View File

0
config/prod/settings.php Normal file → Executable file
View File

0
config/staging/.gitkeep Normal file → Executable file
View File

0
config/staging/db.php Normal file → Executable file
View File

0
config/staging/domaindata.php Normal file → Executable file
View File

0
config/staging/emailtemplates.php Normal file → Executable file
View File

0
config/staging/settings.php Normal file → Executable file
View File

0
debug/.gitkeep Normal file → Executable file
View File

0
partials/.gitkeep Normal file → Executable file
View File

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
$app = app();
$pdo = $app->pdo();
$userId = $_SESSION['user_id'] ?? null;
if (!$userId) {
redirect('/login');
}
$communityCfg = require __DIR__ . '/../../../config/community.php';
$access = $pdo ? new \App\CommunityAccess($pdo, $communityCfg) : null;
$migration = $pdo ? new \App\CommunityMigration($pdo) : null;
if (!$access || !$access->canModerateForum((int)$userId)) {
http_response_code(403);
echo '<main class="section"><div class="container"><div class="card dash-card"><h1>Kein Zugriff</h1><p class="muted">Dieser Bereich ist nur für Community-Admins freigegeben.</p></div></div></main>';
return;
}
$error = '';
$info = '';
$canManageApplications = $access->canManageApplications((int)$userId) && $access->supportsApplications();
$canManageRoles = $access->canManageRoles((int)$userId);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = (string)($_POST['action'] ?? '');
try {
if ($action === 'application_decide') {
$access->decideApplication((int)$userId, (int)($_POST['application_id'] ?? 0), (string)($_POST['decision'] ?? ''), (string)($_POST['decision_reason'] ?? ''));
$info = 'Bewerbung wurde bearbeitet.';
} elseif ($action === 'report_resolve') {
$access->resolveReport((int)$userId, (int)($_POST['report_id'] ?? 0), (string)($_POST['moderator_note'] ?? ''));
$info = 'Meldung wurde abgeschlossen.';
} elseif ($action === 'role_revoke') {
$access->revokeRole((int)$userId, (int)($_POST['target_user_id'] ?? 0), (string)($_POST['role'] ?? ''));
$info = 'Rolle wurde entzogen.';
} elseif ($action === 'role_assign') {
$access->assignRole((int)$userId, (int)($_POST['target_user_id'] ?? 0), (string)($_POST['role'] ?? ''));
$info = 'Rolle wurde vergeben.';
} elseif ($action === 'community_migrate') {
if (!$canManageApplications || !$migration) {
throw new \RuntimeException('Keine Berechtigung für die Community-Migration.');
}
$migration->apply();
$info = 'Die Community-Migration wurde ausgeführt.';
}
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
$applications = $canManageApplications ? $access->listApplications('open') : [];
$reports = $access->supportsReports() ? $access->listOpenReports() : [];
$roleAssignments = $canManageRoles ? $access->listRoleAssignments() : [];
$migrationStatus = ($migration && $canManageApplications) ? $migration->status() : null;
?>
<main class="section">
<div class="container">
<div class="forum-breadcrumbs">
<a href="/">Home</a>
<span>/</span>
<a href="/dashboard">Mitgliederbereich</a>
<span>/</span>
<span>Community-Admin</span>
</div>
<div class="forum-hero forum-hero--compact">
<div>
<h1>Community-Admin</h1>
<p class="forum-hero__copy">Moderation, Bewerbungen und Rollen an einem Ort.</p>
</div>
<div class="forum-hero__cta">
<a class="btn ghost" href="/dashboard">Zurück zum Mitgliederbereich</a>
</div>
</div>
<?php if ($error): ?>
<div class="toast-bar" style="margin-top:14px; border-color:#f87171; color:#991b1b;"><?= htmlspecialchars($error, ENT_QUOTES) ?></div>
<?php endif; ?>
<?php if ($info): ?>
<div class="toast-bar" style="margin-top:14px;"><?= htmlspecialchars($info, ENT_QUOTES) ?></div>
<?php endif; ?>
<div class="forum-admin-grid">
<?php if ($canManageApplications): ?>
<section class="forum-board">
<div class="forum-board__head">
<div>
<h2>Offene Bewerbungen</h2>
<p class="muted">Bewerbungen für die Forum-Moderation prüfen.</p>
</div>
</div>
<div class="forum-admin-list">
<?php foreach ($applications as $application): ?>
<article class="forum-admin-item">
<div>
<strong><?= htmlspecialchars((string)($application['display_name'] ?: $application['email']), ENT_QUOTES) ?></strong>
<p><?= nl2br(htmlspecialchars((string)$application['motivation'], ENT_QUOTES)) ?></p>
</div>
<form method="post" class="forum-admin-item__actions">
<input type="hidden" name="action" value="application_decide">
<input type="hidden" name="application_id" value="<?= (int)$application['id'] ?>">
<textarea name="decision_reason" class="textarea" rows="3" placeholder="Begründung"></textarea>
<div class="flex gap-12">
<button class="btn" type="submit" name="decision" value="approved">Annehmen</button>
<button class="btn ghost" type="submit" name="decision" value="rejected">Ablehnen</button>
</div>
</form>
</article>
<?php endforeach; ?>
<?php if (!$applications): ?>
<div class="forum-empty">Keine offenen Bewerbungen.</div>
<?php endif; ?>
</div>
</section>
<?php endif; ?>
<section class="forum-board">
<div class="forum-board__head">
<div>
<h2>Offene Meldungen</h2>
<p class="muted">Gemeldete Inhalte prüfen und abschließen.</p>
</div>
</div>
<div class="forum-admin-list">
<?php foreach ($reports as $report): ?>
<article class="forum-admin-item">
<div>
<strong><?= htmlspecialchars((string)$report['target_type'], ENT_QUOTES) ?> #<?= (int)$report['target_id'] ?></strong>
<p><?= nl2br(htmlspecialchars((string)$report['reason'], ENT_QUOTES)) ?></p>
<p class="muted small">Gemeldet von <?= htmlspecialchars((string)($report['reporter_name'] ?: 'Mitglied'), ENT_QUOTES) ?> am <?= htmlspecialchars((string)$report['created_at'], ENT_QUOTES) ?></p>
</div>
<form method="post" class="forum-admin-item__actions">
<input type="hidden" name="action" value="report_resolve">
<input type="hidden" name="report_id" value="<?= (int)$report['id'] ?>">
<textarea name="moderator_note" class="textarea" rows="3" placeholder="Moderationsnotiz"></textarea>
<button class="btn" type="submit">Als erledigt markieren</button>
</form>
</article>
<?php endforeach; ?>
<?php if (!$reports): ?>
<div class="forum-empty">Keine offenen Meldungen.</div>
<?php endif; ?>
</div>
</section>
<?php if ($canManageRoles): ?>
<section class="forum-board">
<div class="forum-board__head">
<div>
<h2>Rollen verwalten</h2>
<p class="muted">Community-Rollen vergeben und entziehen.</p>
</div>
</div>
<div style="padding:24px; border-bottom:1px solid var(--color-border);">
<form method="post" class="form-grid single">
<input type="hidden" name="action" value="role_assign">
<div class="stack gap-6">
<label class="label" for="adminTargetUserId">Benutzer-ID</label>
<input id="adminTargetUserId" name="target_user_id" class="input" type="number" min="1" required>
</div>
<div class="stack gap-6">
<label class="label" for="adminRole">Rolle</label>
<select id="adminRole" name="role" class="select">
<option value="forum_admin">forum_admin</option>
<option value="site_admin">site_admin</option>
<option value="owner">owner</option>
</select>
</div>
<div>
<button class="btn" type="submit">Rolle vergeben</button>
</div>
</form>
</div>
<div class="forum-admin-list">
<?php foreach ($roleAssignments as $assignment): ?>
<article class="forum-admin-item">
<div>
<strong><?= htmlspecialchars((string)($assignment['display_name'] ?: $assignment['email']), ENT_QUOTES) ?></strong>
<p class="muted small">Rolle: <?= htmlspecialchars((string)$assignment['role'], ENT_QUOTES) ?> · seit <?= htmlspecialchars((string)$assignment['assigned_at'], ENT_QUOTES) ?></p>
</div>
<form method="post">
<input type="hidden" name="action" value="role_revoke">
<input type="hidden" name="target_user_id" value="<?= (int)$assignment['user_id'] ?>">
<input type="hidden" name="role" value="<?= htmlspecialchars((string)$assignment['role'], ENT_QUOTES) ?>">
<button class="btn ghost" type="submit">Rolle entziehen</button>
</form>
</article>
<?php endforeach; ?>
<?php if (!$roleAssignments): ?>
<div class="forum-empty">Keine Rollen vergeben.</div>
<?php endif; ?>
</div>
</section>
<?php endif; ?>
<?php if ($canManageApplications && $migrationStatus): ?>
<section class="forum-board">
<div class="forum-board__head">
<div>
<h2>Migration</h2>
<p class="muted">Status der Community-Datenbank prüfen.</p>
</div>
</div>
<div style="padding:24px;">
<ul class="dash-list">
<li>Status: <?= !empty($migrationStatus['complete']) ? 'Vollständig eingerichtet' : 'Migration noch offen' ?></li>
<li>Fehlende Bausteine: <?= !empty($migrationStatus['missing']) ? htmlspecialchars(implode(', ', $migrationStatus['missing']), ENT_QUOTES) : 'Keine' ?></li>
</ul>
<form method="post" style="margin-top:14px;">
<input type="hidden" name="action" value="community_migrate">
<button class="btn" type="submit">Community-Migration ausführen</button>
</form>
</div>
</section>
<?php endif; ?>
</div>
</div>
</main>

73
partials/landing/account/dashboard.php Normal file → Executable file
View File

@@ -29,7 +29,62 @@ $allowNoKidsChecked = $editEvent ? ((int)$editEvent['allow_kids'] === 0) : false
</div> </div>
<div class="container dash-section"> <div class="container dash-section">
<div class="dash-grid-2"> <div class="dash-grid">
<?php if (in_array('forum_admin', $communityRoles ?? [], true) || !empty($communityIsSiteAdmin)): ?>
<div class="card dash-card">
<div class="badge">Admin</div>
<h3>Community-Verwaltung</h3>
<div class="flex gap-12" style="margin:12px 0; flex-wrap:wrap;">
<a class="btn" href="/community-admin">Zum Admin-Bereich</a>
</div>
<?php if ($communityMigrationStatus): ?>
<ul class="dash-list">
<li>Status: <?= !empty($communityMigrationStatus['complete']) ? 'Vollständig eingerichtet' : 'Migration noch offen' ?></li>
<li>Fehlende Bausteine: <?= !empty($communityMigrationStatus['missing']) ? htmlspecialchars(implode(', ', $communityMigrationStatus['missing']), ENT_QUOTES) : 'Keine' ?></li>
</ul>
<?php else: ?>
<p class="muted small">Migrationsstatus konnte nicht ermittelt werden.</p>
<?php endif; ?>
<?php if (!empty($communityIsSiteAdmin)): ?>
<form method="post" style="margin-top:12px;">
<input type="hidden" name="action" value="community_migrate">
<button class="btn" type="submit">Community-Migration ausführen</button>
</form>
<?php endif; ?>
<p class="muted small" style="margin-top:10px;">Bündelt Moderation, Bewerbungen, Rollen und bei Bedarf auch die Community-Migration.</p>
</div>
<?php endif; ?>
<div class="card dash-card">
<div class="badge">Community</div>
<h3>Dein Community-Status</h3>
<ul class="dash-list">
<li>Rang: <?= htmlspecialchars(trim(($communityLevel['icon'] ?? '') . ' ' . ($communityLevel['label'] ?? '')), ENT_QUOTES) ?></li>
<li>Punkte: <?= number_format((float)$communityPoints, 1, ',', '.') ?></li>
<li>Rollen: <?= htmlspecialchars($communityRoles ? implode(', ', $communityRoles) : 'Keine besonderen Rollen', ENT_QUOTES) ?></li>
<li>Themen erstellen: <?= $communityRestrictions['thread_create_blocked'] ? 'Gesperrt' : 'Erlaubt' ?></li>
<li>Antworten schreiben: <?= $communityRestrictions['reply_blocked'] ? 'Gesperrt' : 'Erlaubt' ?></li>
</ul>
<?php if (!empty($communityRestrictions['reason'])): ?>
<p class="muted small" style="margin-top:10px;">Hinweis zur Community-Sperre: <?= htmlspecialchars((string)$communityRestrictions['reason'], ENT_QUOTES) ?></p>
<?php endif; ?>
<?php if ($communityCanApply): ?>
<form method="post" class="stack gap-6" style="margin-top:12px;">
<input type="hidden" name="action" value="community_admin_apply">
<label class="label" for="communityMotivation">Bewerbung als Forum-Admin</label>
<textarea id="communityMotivation" name="motivation" class="textarea" rows="4" placeholder="Warum möchtest du die Community als Forum-Admin unterstützen?" required></textarea>
<button class="btn" type="submit">Bewerbung absenden</button>
</form>
<?php elseif ($communityApplication): ?>
<div style="margin-top:12px;">
<p class="muted small" style="margin:0;">Letzte Bewerbung: <strong><?= htmlspecialchars((string)$communityApplication['status'], ENT_QUOTES) ?></strong></p>
<?php if (!empty($communityApplication['decision_reason'])): ?>
<p class="muted small" style="margin-top:6px;"><?= htmlspecialchars((string)$communityApplication['decision_reason'], ENT_QUOTES) ?></p>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<div class="card dash-card"> <div class="card dash-card">
<div class="badge">Profil</div> <div class="badge">Profil</div>
<h3>Deine Angaben</h3> <h3>Deine Angaben</h3>
@@ -39,6 +94,13 @@ $allowNoKidsChecked = $editEvent ? ((int)$editEvent['allow_kids'] === 0) : false
<li>Ort: <?= htmlspecialchars($profile['city'], ENT_QUOTES) ?> <?= htmlspecialchars($profile['zip'], ENT_QUOTES) ?></li> <li>Ort: <?= htmlspecialchars($profile['city'], ENT_QUOTES) ?> <?= htmlspecialchars($profile['zip'], ENT_QUOTES) ?></li>
<li>E-Mail: <?= htmlspecialchars($profile['email'], ENT_QUOTES) ?></li> <li>E-Mail: <?= htmlspecialchars($profile['email'], ENT_QUOTES) ?></li>
<li>Telefon: <?= htmlspecialchars($profile['contact_phone'], ENT_QUOTES) ?></li> <li>Telefon: <?= htmlspecialchars($profile['contact_phone'], ENT_QUOTES) ?></li>
<li>Standortfreigabe: <?=
htmlspecialchars(match ($profile['location_tracking_preference'] ?? 'prompt') {
'enabled' => 'Immer verwenden',
'disabled' => 'Deaktiviert',
default => 'Beim nächsten Mal fragen',
}, ENT_QUOTES)
?></li>
<li>Beruf: <?= htmlspecialchars($profile['profession'], ENT_QUOTES) ?></li> <li>Beruf: <?= htmlspecialchars($profile['profession'], ENT_QUOTES) ?></li>
<li>Sprachen: <?= htmlspecialchars($profile['languages'], ENT_QUOTES) ?></li> <li>Sprachen: <?= htmlspecialchars($profile['languages'], ENT_QUOTES) ?></li>
<li>About: <?= htmlspecialchars($profile['about'], ENT_QUOTES) ?></li> <li>About: <?= htmlspecialchars($profile['about'], ENT_QUOTES) ?></li>
@@ -201,6 +263,15 @@ $allowNoKidsChecked = $editEvent ? ((int)$editEvent['allow_kids'] === 0) : false
<label class="label" for="pProf">Beruf</label> <label class="label" for="pProf">Beruf</label>
<input id="pProf" name="profession" class="input" value="<?= htmlspecialchars($profile['profession'], ENT_QUOTES) ?>"> <input id="pProf" name="profession" class="input" value="<?= htmlspecialchars($profile['profession'], ENT_QUOTES) ?>">
</div> </div>
<div class="stack gap-6">
<label class="label" for="pLocationPref">Standortfreigabe</label>
<select id="pLocationPref" name="location_tracking_preference" class="select">
<option value="disabled" <?= (($profile['location_tracking_preference'] ?? 'prompt') === 'disabled') ? 'selected' : '' ?>>Deaktiviert</option>
<option value="prompt" <?= (($profile['location_tracking_preference'] ?? 'prompt') === 'prompt') ? 'selected' : '' ?>>Beim nächsten Mal fragen</option>
<option value="enabled" <?= (($profile['location_tracking_preference'] ?? 'prompt') === 'enabled') ? 'selected' : '' ?>>Immer verwenden</option>
</select>
<p class="muted small">Bei aktiver Freigabe wird dein aktueller Standort für passende Treffen in deiner Nähe verwendet. Du kannst die Abfrage hier jederzeit wieder auf Nachfrage oder komplett aus stellen.</p>
</div>
<div class="stack gap-6"> <div class="stack gap-6">
<label class="label" for="pAbout">Kurzvorstellung</label> <label class="label" for="pAbout">Kurzvorstellung</label>
<textarea id="pAbout" name="about" class="textarea" rows="3"><?= htmlspecialchars($profile['about'], ENT_QUOTES) ?></textarea> <textarea id="pAbout" name="about" class="textarea" rows="3"><?= htmlspecialchars($profile['about'], ENT_QUOTES) ?></textarea>

0
partials/landing/account/login.php Normal file → Executable file
View File

0
partials/landing/account/register.php Normal file → Executable file
View File

0
partials/landing/account/verify.php Normal file → Executable file
View File

View File

@@ -5,75 +5,237 @@ $app = app();
$pdo = $app->pdo(); $pdo = $app->pdo();
$userId = $_SESSION['user_id'] ?? null; $userId = $_SESSION['user_id'] ?? null;
$error = ''; $error = '';
$info = '';
$search = trim((string)($_GET['q'] ?? '')); $search = trim((string)($_GET['q'] ?? ''));
$boardSlug = trim((string)($_GET['board'] ?? ''));
$communityCfg = require __DIR__ . '/../../../config/community.php'; $communityCfg = require __DIR__ . '/../../../config/community.php';
$community = $pdo ? new \App\Community($pdo, $communityCfg) : null; $community = $pdo ? new \App\Community($pdo, $communityCfg) : null;
$access = $pdo ? new \App\CommunityAccess($pdo, $communityCfg) : null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'thread_create') { $forumCategories = $community ? $community->listForumCategories() : [];
if (!$userId) { $selectedBoard = ($community && $boardSlug !== '') ? $community->getBoardBySlug($boardSlug) : null;
$error = 'Bitte einloggen, um Fragen zu stellen.';
} elseif ($community) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) {
$title = trim((string)($_POST['title'] ?? '')); $action = (string)($_POST['action'] ?? '');
$body = trim((string)($_POST['body'] ?? '')); try {
if ($title === '' || $body === '') { if ($action === 'thread_create') {
$error = 'Titel und Text sind erforderlich.'; if (!$userId) {
} else { throw new \RuntimeException('Bitte einloggen, um Themen zu erstellen.');
$community->createThread((int)$userId, $title, $body); }
redirect('/community'); if (!$access->canCreateThread((int)$userId)) {
$state = $access->getRestrictionState((int)$userId);
throw new \RuntimeException($state['reason'] ?: 'Du darfst aktuell keine Themen erstellen.');
}
$community->createThreadInBoard((int)$userId, (string)($_POST['board_slug'] ?? ''), (string)($_POST['title'] ?? ''), (string)($_POST['body'] ?? ''));
redirect('/community' . ($boardSlug !== '' ? '?board=' . rawurlencode($boardSlug) : ''));
} }
} catch (\Throwable $e) {
$error = $e->getMessage();
} }
} }
$threads = $community ? ($search !== '' ? $community->searchThreads($search, 50) : $community->listThreads(50)) : []; $threads = $community
? ($search !== '' ? $community->searchThreads($search, 50, $boardSlug !== '' ? $boardSlug : null) : $community->listThreads(50, $boardSlug !== '' ? $boardSlug : null))
: [];
?> ?>
<main class="section"> <main class="section">
<div class="container"> <div class="container">
<p class="eyebrow">Community</p> <div class="forum-hero">
<h1>Forum</h1> <div>
<div class="forum-breadcrumbs">
<a href="/">Home</a>
<span>/</span>
<span>Community</span>
<?php if ($selectedBoard): ?>
<span>/</span>
<span><?= htmlspecialchars((string)$selectedBoard['title'], ENT_QUOTES) ?></span>
<?php endif; ?>
</div>
<h1>Community</h1>
<p class="forum-hero__copy">Fragen stellen, Erfahrungen teilen, Antworten finden und gemeinsam weiterdenken.</p>
</div>
<div class="forum-hero__actions forum-hero__actions--stack">
<form method="get" class="forum-search forum-search--hero">
<?php if ($boardSlug !== ''): ?>
<input type="hidden" name="board" value="<?= htmlspecialchars($boardSlug, ENT_QUOTES) ?>">
<?php endif; ?>
<label class="label" for="searchQHero">Suche</label>
<div class="forum-search__row">
<input id="searchQHero" name="q" class="input" value="<?= htmlspecialchars($search, ENT_QUOTES) ?>" placeholder="Thema oder Stichwort suchen">
<button class="btn" type="submit">Suchen</button>
</div>
</form>
<div class="forum-hero__cta">
<?php if ($userId): ?>
<button class="btn" type="button" data-modal-open="modalThread">Neues Thema</button>
<?php else: ?>
<a class="btn" href="/login">Einloggen und schreiben</a>
<?php endif; ?>
</div>
</div>
</div>
<?php if ($error): ?> <?php if ($error): ?>
<div class="toast-bar" style="border-color:#f87171; color:#991b1b;"><?= htmlspecialchars($error, ENT_QUOTES) ?></div> <div class="toast-bar" style="margin-top:14px; border-color:#f87171; color:#991b1b;"><?= htmlspecialchars($error, ENT_QUOTES) ?></div>
<?php endif; ?>
<?php if ($info): ?>
<div class="toast-bar" style="margin-top:14px;"><?= htmlspecialchars($info, ENT_QUOTES) ?></div>
<?php endif; ?> <?php endif; ?>
<div class="flex between center-y" style="margin:14px 0; gap:12px; flex-wrap:wrap;"> <?php if ($selectedBoard): ?>
<form method="get" class="flex gap-8" style="flex-wrap:wrap; align-items:flex-end;"> <div class="forum-shell">
<div class="stack gap-4">
<label class="label" for="searchQ">Suche nach Stichwort</label>
<input id="searchQ" name="q" class="input" value="<?= htmlspecialchars($search, ENT_QUOTES) ?>" placeholder="Schlagwort eingeben">
</div>
<button class="btn" type="submit">Suchen</button>
</form>
<?php if ($userId): ?>
<button class="btn" type="button" data-modal-open="modalThread">Neue Frage</button>
<?php else: ?>
<a class="btn ghost" href="/login">Login für neue Frage</a>
<?php endif; ?>
</div>
<div class="stack gap-8" style="margin-top:10px;">
<?php foreach ($threads as $t): ?>
<?php <?php
$pts = ($community && $pdo) ? $community->computePoints((int)$t['uid']) : 0.0; $sidebarCategories = $forumCategories;
$lvl = $community ? $community->membershipLevel($pts) : ['label'=>'','icon'=>'']; $sidebarCurrentBoardSlug = (string)$selectedBoard['slug'];
require __DIR__ . '/sidebar.php';
?> ?>
<article class="card" style="padding:14px;"> <div class="forum-shell__main">
<div class="event__body"> <div class="forum-board">
<div class="event__meta" style="flex-wrap:wrap; gap:8px;"> <div class="forum-board__head">
<span><?= htmlspecialchars($t['created_at'], ENT_QUOTES) ?></span> <div>
<span><?= htmlspecialchars($t['display_name'] ?: 'Mitglied', ENT_QUOTES) ?></span> <h2><?= htmlspecialchars($selectedBoard['title'], ENT_QUOTES) ?></h2>
<span><?= htmlspecialchars($lvl['icon'] ?? '', ENT_QUOTES) ?> <?= htmlspecialchars($lvl['label'] ?? '', ENT_QUOTES) ?> (<?= number_format($pts,1) ?> Punkte)</span> <p class="muted"><?= htmlspecialchars((string)$selectedBoard['description'], ENT_QUOTES) ?></p>
<span>Beiträge: <?= (int)$t['user_posts'] + (int)$t['answers'] ?></span> </div>
<span>Antworten: <?= (int)$t['answers'] ?></span> <a class="btn ghost" href="/community">Alle Bereiche anzeigen</a>
</div>
<div class="forum-list">
<div class="forum-list__header">
<span>Thema</span>
<span>Antworten</span>
<span>Letzte Aktivität</span>
</div>
<?php foreach ($threads as $t): ?>
<?php
$pts = ($community && $pdo) ? $community->computePoints((int)$t['uid']) : 0.0;
$lvl = $community ? $community->membershipLevel($pts) : ['label'=>'','icon'=>''];
$preview = mb_substr((string)$t['body'], 0, 220);
?>
<article class="forum-row">
<div class="forum-row__topic">
<div class="forum-row__icon" aria-hidden="true">💬</div>
<div>
<h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3>
<div class="forum-row__meta">
<span><?= htmlspecialchars((string)($t['display_name'] ?: 'Mitglied'), ENT_QUOTES) ?></span>
<span><?= htmlspecialchars(trim((string)($lvl['icon'] ?? '') . ' ' . (string)($lvl['label'] ?? '')), ENT_QUOTES) ?></span>
</div>
<p><?= nl2br(htmlspecialchars($preview, ENT_QUOTES)) ?><?= mb_strlen((string)$t['body']) > 220 ? '…' : '' ?></p>
</div>
</div>
<div class="forum-row__count">
<strong><?= (int)$t['answers'] ?></strong>
<span>Antworten</span>
</div>
<div class="forum-row__activity">
<strong><?= htmlspecialchars((string)($t['last_activity_by'] ?: 'Mitglied'), ENT_QUOTES) ?></strong>
<span><?= htmlspecialchars((string)$t['last_activity_at'], ENT_QUOTES) ?></span>
</div>
</article>
<?php endforeach; ?>
<?php if (!$threads): ?>
<div class="forum-empty">In diesem Bereich gibt es noch keine Themen.</div>
<?php endif; ?>
</div> </div>
<h3 style="margin:6px 0;"><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars($t['title'], ENT_QUOTES) ?></a></h3>
<p class="muted small"><?= nl2br(htmlspecialchars(substr($t['body'], 0, 200), ENT_QUOTES)) ?><?= strlen($t['body']) > 200 ? '…' : '' ?></p>
</div> </div>
</article> </div>
<?php endforeach; ?> </div>
<?php if (!$threads): ?> <?php else: ?>
<p class="muted">Keine Treffer.</p> <div class="forum-category-list">
<?php endif; ?> <?php foreach ($forumCategories as $category): ?>
</div> <section class="forum-category-card">
<div class="forum-category-card__title"><?= htmlspecialchars((string)$category['title'], ENT_QUOTES) ?></div>
<div class="forum-board-grid">
<?php foreach ($category['boards'] as $board): ?>
<?php $latest = $board['latest_thread'] ?? null; ?>
<article class="forum-board-row">
<div class="forum-board-row__main">
<div class="forum-row__icon" aria-hidden="true"><?= htmlspecialchars((string)($board['icon'] ?? '💬'), ENT_QUOTES) ?></div>
<div>
<h3>
<a href="/community?board=<?= rawurlencode((string)$board['slug']) ?>">
<?= htmlspecialchars((string)$board['title'], ENT_QUOTES) ?>
</a>
</h3>
<p><?= htmlspecialchars((string)$board['description'], ENT_QUOTES) ?></p>
<div class="forum-row__meta">
<span>Themen: <?= (int)($board['thread_count'] ?? 0) ?></span>
<span>Beiträge: <?= (int)($board['post_count'] ?? 0) ?></span>
</div>
</div>
</div>
<div class="forum-board-row__latest">
<?php if ($latest): ?>
<strong><a href="/community_thread?id=<?= (int)$latest['id'] ?>"><?= htmlspecialchars((string)$latest['title'], ENT_QUOTES) ?></a></strong>
<span><?= htmlspecialchars((string)($latest['last_activity_by'] ?: 'Mitglied'), ENT_QUOTES) ?></span>
<span><?= htmlspecialchars((string)$latest['last_activity_at'], ENT_QUOTES) ?></span>
<?php else: ?>
<span class="muted">Noch keine Themen</span>
<?php endif; ?>
</div>
</article>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
</div>
<div class="forum-board" style="margin-top:24px;">
<div class="forum-board__head">
<div>
<h2>Neueste Themen</h2>
<p class="muted">Hier siehst du die zuletzt erstellten oder zuletzt aktiven Themen aus der Community.</p>
</div>
</div>
<div class="forum-list">
<div class="forum-list__header">
<span>Thema</span>
<span>Antworten</span>
<span>Letzte Aktivität</span>
</div>
<?php foreach ($threads as $t): ?>
<?php
$pts = ($community && $pdo) ? $community->computePoints((int)$t['uid']) : 0.0;
$lvl = $community ? $community->membershipLevel($pts) : ['label'=>'','icon'=>''];
$preview = mb_substr((string)$t['body'], 0, 220);
?>
<article class="forum-row">
<div class="forum-row__topic">
<div class="forum-row__icon" aria-hidden="true">💬</div>
<div>
<h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3>
<div class="forum-row__meta">
<span><?= htmlspecialchars((string)($t['display_name'] ?: 'Mitglied'), ENT_QUOTES) ?></span>
<span><?= htmlspecialchars(trim((string)($lvl['icon'] ?? '') . ' ' . (string)($lvl['label'] ?? '')), ENT_QUOTES) ?></span>
<?php if (!empty($t['board_title'])): ?>
<span><?= htmlspecialchars((string)$t['board_title'], ENT_QUOTES) ?></span>
<?php endif; ?>
</div>
<p><?= nl2br(htmlspecialchars($preview, ENT_QUOTES)) ?><?= mb_strlen((string)$t['body']) > 220 ? '…' : '' ?></p>
</div>
</div>
<div class="forum-row__count">
<strong><?= (int)$t['answers'] ?></strong>
<span>Antworten</span>
</div>
<div class="forum-row__activity">
<strong><?= htmlspecialchars((string)($t['last_activity_by'] ?: 'Mitglied'), ENT_QUOTES) ?></strong>
<span><?= htmlspecialchars((string)$t['last_activity_at'], ENT_QUOTES) ?></span>
</div>
</article>
<?php endforeach; ?>
<?php if (!$threads): ?>
<div class="forum-empty">Keine Themen gefunden.</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div> </div>
</main> </main>
@@ -81,22 +243,36 @@ $threads = $community ? ($search !== '' ? $community->searchThreads($search, 50)
<div class="modal" id="modalThread"> <div class="modal" id="modalThread">
<div class="panel"> <div class="panel">
<div class="head flex between center-y"> <div class="head flex between center-y">
<h3 style="margin:0;">Neue Frage</h3> <h3 style="margin:0;">Neues Thema</h3>
<button class="btn ghost" type="button" data-modal-close>✕</button> <button class="btn ghost" type="button" data-modal-close>✕</button>
</div> </div>
<form method="post" class="stack gap-10" style="margin-top:12px;"> <form method="post" class="stack gap-10" style="margin-top:12px;">
<input type="hidden" name="action" value="thread_create"> <input type="hidden" name="action" value="thread_create">
<div class="stack gap-6">
<label class="label" for="fBoardModal">Unterforum</label>
<select id="fBoardModal" name="board_slug" class="select">
<?php foreach ($forumCategories as $category): ?>
<optgroup label="<?= htmlspecialchars((string)$category['title'], ENT_QUOTES) ?>">
<?php foreach ($category['boards'] as $board): ?>
<option value="<?= htmlspecialchars((string)$board['slug'], ENT_QUOTES) ?>" <?= $boardSlug === $board['slug'] ? 'selected' : '' ?>>
<?= htmlspecialchars((string)$board['title'], ENT_QUOTES) ?>
</option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
</select>
</div>
<div class="stack gap-6"> <div class="stack gap-6">
<label class="label" for="fTitleModal">Frage/Titel</label> <label class="label" for="fTitleModal">Frage/Titel</label>
<input id="fTitleModal" name="title" class="input" required> <input id="fTitleModal" name="title" class="input" required>
</div> </div>
<div class="stack gap-6"> <div class="stack gap-6">
<label class="label" for="fBodyModal">Text</label> <label class="label" for="fBodyModal">Text</label>
<textarea id="fBodyModal" name="body" class="textarea" rows="4" required></textarea> <textarea id="fBodyModal" name="body" class="textarea" rows="5" required></textarea>
</div> </div>
<div class="flex gap-12" style="flex-wrap:wrap;"> <div class="flex gap-12" style="flex-wrap:wrap;">
<button class="btn ghost" type="button" data-modal-close>Abbrechen</button> <button class="btn ghost" type="button" data-modal-close>Abbrechen</button>
<button class="btn" type="submit">Frage erstellen</button> <button class="btn" type="submit">Thema erstellen</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
$sidebarCategories = $sidebarCategories ?? [];
$sidebarCurrentBoardSlug = (string)($sidebarCurrentBoardSlug ?? '');
?>
<aside class="forum-sidebar">
<div class="forum-sidebar__inner">
<div class="forum-sidebar__head">
<h2>Navigation</h2>
</div>
<div class="forum-sidebar__tree">
<?php foreach ($sidebarCategories as $sidebarCategory): ?>
<section class="forum-sidebar__group">
<h3><?= htmlspecialchars((string)$sidebarCategory['title'], ENT_QUOTES) ?></h3>
<ul>
<?php foreach ($sidebarCategory['boards'] as $sidebarBoard): ?>
<li>
<a class="<?= $sidebarCurrentBoardSlug === (string)$sidebarBoard['slug'] ? 'is-active' : '' ?>" href="/community?board=<?= rawurlencode((string)$sidebarBoard['slug']) ?>">
<span><?= htmlspecialchars((string)$sidebarBoard['title'], ENT_QUOTES) ?></span>
<small><?= (int)($sidebarBoard['thread_count'] ?? 0) ?></small>
</a>
</li>
<?php endforeach; ?>
</ul>
</section>
<?php endforeach; ?>
</div>
</div>
</aside>

View File

@@ -5,22 +5,89 @@ $app = app();
$pdo = $app->pdo(); $pdo = $app->pdo();
$userId = $_SESSION['user_id'] ?? null; $userId = $_SESSION['user_id'] ?? null;
$error = ''; $error = '';
$info = '';
$threadId = (int)($_GET['id'] ?? 0); $threadId = (int)($_GET['id'] ?? 0);
$editPostId = (int)($_GET['edit_post'] ?? 0);
$communityCfg = require __DIR__ . '/../../../config/community.php'; $communityCfg = require __DIR__ . '/../../../config/community.php';
$community = $pdo ? new \App\Community($pdo, $communityCfg) : null; $community = $pdo ? new \App\Community($pdo, $communityCfg) : null;
$access = $pdo ? new \App\CommunityAccess($pdo, $communityCfg) : null;
$forumCategories = $community ? $community->listForumCategories() : [];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'reply') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) {
if (!$userId) { $action = (string)($_POST['action'] ?? '');
$error = 'Bitte einloggen, um zu antworten.'; try {
} elseif ($community) { if ($action === 'reply') {
$body = trim((string)($_POST['body'] ?? '')); if (!$userId) {
if ($body === '') { throw new \RuntimeException('Bitte einloggen, um zu antworten.');
$error = 'Antwort darf nicht leer sein.'; }
} else { if (!$access->canReply((int)$userId)) {
$state = $access->getRestrictionState((int)$userId);
throw new \RuntimeException($state['reason'] ?: 'Du darfst aktuell nicht antworten.');
}
$body = trim((string)($_POST['body'] ?? ''));
if ($body === '') {
throw new \RuntimeException('Antwort darf nicht leer sein.');
}
$community->createPost((int)$userId, $threadId, $body); $community->createPost((int)$userId, $threadId, $body);
redirect('/community_thread?id=' . $threadId); redirect('/community_thread?id=' . $threadId);
} elseif ($action === 'post_vote') {
if (!$userId) {
throw new \RuntimeException('Bitte einloggen, um Beiträge zu bewerten.');
}
$access->votePost((int)$userId, (int)($_POST['post_id'] ?? 0), (int)($_POST['value'] ?? 0));
redirect('/community_thread?id=' . $threadId);
} elseif ($action === 'report_content') {
if (!$userId) {
throw new \RuntimeException('Bitte einloggen, um Beiträge zu melden.');
}
$access->submitReport((int)$userId, (string)($_POST['target_type'] ?? ''), (int)($_POST['target_id'] ?? 0), (string)($_POST['reason'] ?? ''));
$info = 'Deine Meldung wurde eingereicht.';
} elseif ($action === 'post_update') {
if (!$userId || !$access->canModerateForum((int)$userId)) {
throw new \RuntimeException('Keine Berechtigung.');
}
$community->updatePost((int)($_POST['post_id'] ?? 0), (string)($_POST['body'] ?? ''));
redirect('/community_thread?id=' . $threadId);
} elseif ($action === 'post_delete') {
if (!$userId || !$access->canModerateForum((int)$userId)) {
throw new \RuntimeException('Keine Berechtigung.');
}
$community->deletePost((int)($_POST['post_id'] ?? 0));
redirect('/community_thread?id=' . $threadId);
} elseif ($action === 'thread_update') {
if (!$userId || !$access->canModerateForum((int)$userId)) {
throw new \RuntimeException('Keine Berechtigung.');
}
$community->updateThread($threadId, (string)($_POST['title'] ?? ''), (string)($_POST['body'] ?? ''));
redirect('/community_thread?id=' . $threadId);
} elseif ($action === 'thread_delete') {
if (!$userId || !$access->canModerateForum((int)$userId)) {
throw new \RuntimeException('Keine Berechtigung.');
}
$community->deleteThread($threadId);
redirect('/community');
} elseif ($action === 'highlight_post') {
if (!$userId) {
throw new \RuntimeException('Bitte einloggen.');
}
$points = $community->computePoints((int)$userId);
if (!$access->canHighlightHelpful($points) && !$access->canModerateForum((int)$userId)) {
throw new \RuntimeException('Dafür ist mindestens der Rang Mentor-Vater erforderlich.');
}
$postId = (int)($_POST['post_id'] ?? 0);
$highlight = ((string)($_POST['highlight'] ?? '1')) === '1';
$community->toggleHelpfulHighlight($postId, $highlight ? (int)$userId : null);
redirect('/community_thread?id=' . $threadId);
} elseif ($action === 'restriction_set') {
$access->setRestriction((int)$userId, (int)($_POST['target_user_id'] ?? 0), (string)($_POST['restriction_type'] ?? ''), (string)($_POST['reason'] ?? ''));
$info = 'Community-Sperre wurde gesetzt.';
} elseif ($action === 'restriction_clear') {
$access->clearRestriction((int)$userId, (int)($_POST['target_user_id'] ?? 0), (string)($_POST['restriction_type'] ?? ''));
$info = 'Community-Sperre wurde aufgehoben.';
} }
} catch (\Throwable $e) {
$error = $e->getMessage();
} }
} }
@@ -32,51 +99,261 @@ if (!$thread) {
echo "<p>Thread nicht gefunden.</p>"; echo "<p>Thread nicht gefunden.</p>";
return; return;
} }
$threadAuthor = (string)($thread['display_name'] ?: 'Mitglied');
$threadPoints = ($community && $pdo) ? $community->computePoints((int)$thread['user_id']) : 0.0;
$threadLevel = $community ? $community->membershipLevel($threadPoints) : ['label' => '', 'icon' => ''];
$userRoles = $access && $userId ? $access->getUserRoles((int)$userId) : [];
$isForumAdmin = $access && $userId ? $access->canModerateForum((int)$userId) : false;
$userRestrictions = $access && $userId ? $access->getRestrictionState((int)$userId) : ['reply_blocked' => false, 'reason' => null];
$postIds = array_map(static fn(array $post): int => (int)$post['id'], $posts);
$feedbackSummary = $access ? $access->getPostFeedbackSummary($postIds) : [];
$userVotes = ($access && $userId) ? $access->getUserPostVotes((int)$userId, $postIds) : [];
?> ?>
<main class="section"> <main class="section">
<div class="container"> <div class="container">
<p class="eyebrow">Community</p> <div class="forum-breadcrumbs">
<h1><?= htmlspecialchars($thread['title'], ENT_QUOTES) ?></h1> <a href="/">Home</a>
<p class="muted">Von <?= htmlspecialchars($thread['display_name'] ?: 'Mitglied', ENT_QUOTES) ?> · <?= htmlspecialchars($thread['created_at'], ENT_QUOTES) ?></p> <span>/</span>
<article class="card" style="margin-top:12px;"> <a href="/community">Community</a>
<div class="event__body"> <?php if (!empty($thread['board_title'])): ?>
<p><?= nl2br(htmlspecialchars($thread['body'], ENT_QUOTES)) ?></p> <span>/</span>
</div> <a href="/community?board=<?= rawurlencode((string)$thread['board_slug']) ?>"><?= htmlspecialchars((string)$thread['board_title'], ENT_QUOTES) ?></a>
</article>
<h3 style="margin-top:16px;">Antworten (<?= count($posts) ?>)</h3>
<div class="stack gap-12" style="margin-top:10px;">
<?php foreach ($posts as $p): ?>
<article class="card">
<div class="event__body">
<div class="event__meta">
<span><?= htmlspecialchars($p['created_at'], ENT_QUOTES) ?></span>
<span><?= htmlspecialchars($p['display_name'] ?: 'Mitglied', ENT_QUOTES) ?></span>
</div>
<p><?= nl2br(htmlspecialchars($p['body'], ENT_QUOTES)) ?></p>
</div>
</article>
<?php endforeach; ?>
<?php if (!$posts): ?>
<p class="muted">Noch keine Antworten.</p>
<?php endif; ?> <?php endif; ?>
<span>/</span>
<span><?= htmlspecialchars((string)$thread['title'], ENT_QUOTES) ?></span>
</div> </div>
<?php if ($error): ?> <div class="forum-shell">
<div class="toast-bar" style="margin-top:12px; border-color:#f87171; color:#991b1b;"><?= htmlspecialchars($error, ENT_QUOTES) ?></div> <?php
<?php endif; ?> $sidebarCategories = $forumCategories;
$sidebarCurrentBoardSlug = (string)($thread['board_slug'] ?? '');
<?php if ($userId): ?> require __DIR__ . '/sidebar.php';
<form method="post" class="stack gap-12 card" style="margin-top:14px; padding:16px;"> ?>
<input type="hidden" name="action" value="reply"> <div class="forum-shell__main">
<div class="stack gap-6"> <div class="forum-thread-head">
<label class="label" for="replyBody">Antwort</label> <div>
<textarea id="replyBody" name="body" class="textarea" rows="4" required></textarea> <h1><?= htmlspecialchars((string)$thread['title'], ENT_QUOTES) ?></h1>
<p class="forum-thread-head__meta">Erstellt von <?= htmlspecialchars($threadAuthor, ENT_QUOTES) ?> am <?= htmlspecialchars((string)$thread['created_at'], ENT_QUOTES) ?></p>
<?php if (!empty($thread['board_title'])): ?>
<p class="muted small" style="margin:8px 0 0;">Bereich: <?= htmlspecialchars((string)$thread['board_title'], ENT_QUOTES) ?></p>
<?php endif; ?>
</div>
<div class="forum-thread-head__meta-box">
<strong><?= count($posts) ?></strong>
<span>Antworten</span>
</div>
</div> </div>
<button class="btn" type="submit">Antwort senden</button>
</form> <?php if ($error): ?>
<?php else: ?> <div class="toast-bar" style="margin-bottom:14px; border-color:#f87171; color:#991b1b;"><?= htmlspecialchars($error, ENT_QUOTES) ?></div>
<p class="muted" style="margin-top:12px;">Bitte <a href="/login">einloggen</a>, um zu antworten.</p> <?php endif; ?>
<?php endif; ?> <?php if ($info): ?>
<div class="toast-bar" style="margin-bottom:14px;"><?= htmlspecialchars($info, ENT_QUOTES) ?></div>
<?php endif; ?>
<article class="forum-post forum-post--lead">
<aside class="forum-post__author">
<div class="forum-post__avatar" aria-hidden="true"><?= strtoupper(mb_substr($threadAuthor, 0, 1)) ?></div>
<strong><?= htmlspecialchars($threadAuthor, ENT_QUOTES) ?></strong>
<span><?= htmlspecialchars(trim((string)($threadLevel['icon'] ?? '') . ' ' . (string)($threadLevel['label'] ?? '')), ENT_QUOTES) ?></span>
<span><?= number_format($threadPoints, 1) ?> Punkte</span>
<?php if ($isForumAdmin): ?>
<div class="forum-post__admin">
<button class="btn ghost" type="button" data-modal-open="modalThreadEdit">Thema bearbeiten</button>
<form method="post" onsubmit="return confirm('Thema wirklich löschen?');">
<input type="hidden" name="action" value="thread_delete">
<button class="btn ghost" type="submit">Thema löschen</button>
</form>
</div>
<?php endif; ?>
</aside>
<div class="forum-post__body">
<div class="forum-post__head">
<span><?= htmlspecialchars((string)$thread['created_at'], ENT_QUOTES) ?></span>
<span>#1</span>
</div>
<div class="forum-post__content">
<?= nl2br(htmlspecialchars((string)$thread['body'], ENT_QUOTES)) ?>
</div>
<?php if ($userId && $access && $access->supportsReports()): ?>
<form method="post" class="forum-inline-report">
<input type="hidden" name="action" value="report_content">
<input type="hidden" name="target_type" value="thread">
<input type="hidden" name="target_id" value="<?= (int)$thread['id'] ?>">
<input type="hidden" name="reason" value="Thread benötigt Moderation oder Prüfung.">
<button class="btn ghost" type="submit">Thema melden</button>
</form>
<?php endif; ?>
</div>
</article>
<div class="forum-replies-head">
<h2>Antworten</h2>
<span><?= count($posts) ?> Beitrag/Beiträge</span>
</div>
<div class="forum-replies">
<?php foreach ($posts as $index => $p): ?>
<?php
$postAuthor = (string)($p['display_name'] ?: 'Mitglied');
$summary = $feedbackSummary[(int)$p['id']] ?? ['helpful' => 0, 'unhelpful' => 0];
$userVote = $userVotes[(int)$p['id']] ?? 0;
$isHighlighted = !empty($p['highlighted_at']);
?>
<article class="forum-post<?= $isHighlighted ? ' forum-post--highlighted' : '' ?>">
<aside class="forum-post__author">
<div class="forum-post__avatar" aria-hidden="true"><?= strtoupper(mb_substr($postAuthor, 0, 1)) ?></div>
<strong><?= htmlspecialchars($postAuthor, ENT_QUOTES) ?></strong>
<?php if ($isHighlighted): ?>
<span class="forum-highlight-badge">Hilfreich hervorgehoben</span>
<?php endif; ?>
<?php if ($isForumAdmin && $access && $access->supportsRestrictions()): ?>
<form method="post" class="forum-admin-mini-form">
<input type="hidden" name="action" value="restriction_set">
<input type="hidden" name="target_user_id" value="<?= (int)$p['user_id'] ?>">
<input type="hidden" name="restriction_type" value="reply_blocked">
<input type="hidden" name="reason" value="Antworten in der Community vorübergehend gesperrt.">
<button class="btn ghost" type="submit">Antworten sperren</button>
</form>
<?php endif; ?>
</aside>
<div class="forum-post__body">
<div class="forum-post__head">
<span><?= htmlspecialchars((string)$p['created_at'], ENT_QUOTES) ?></span>
<span>#<?= $index + 2 ?></span>
</div>
<div class="forum-post__content">
<?php if ($editPostId === (int)$p['id'] && $isForumAdmin): ?>
<form method="post" class="stack gap-10">
<input type="hidden" name="action" value="post_update">
<input type="hidden" name="post_id" value="<?= (int)$p['id'] ?>">
<textarea name="body" class="textarea" rows="6" required><?= htmlspecialchars((string)$p['body'], ENT_QUOTES) ?></textarea>
<div class="flex gap-12">
<button class="btn" type="submit">Änderung speichern</button>
<a class="btn ghost" href="/community_thread?id=<?= $threadId ?>">Abbrechen</a>
</div>
</form>
<?php else: ?>
<?= nl2br(htmlspecialchars((string)$p['body'], ENT_QUOTES)) ?>
<?php endif; ?>
</div>
<div class="forum-post__toolbar">
<?php if ($userId && $access && $access->supportsFeedback()): ?>
<form method="post">
<input type="hidden" name="action" value="post_vote">
<input type="hidden" name="post_id" value="<?= (int)$p['id'] ?>">
<input type="hidden" name="value" value="1">
<button class="btn ghost<?= $userVote === 1 ? ' is-active' : '' ?>" type="submit">Hilfreich (<?= (int)$summary['helpful'] ?>)</button>
</form>
<form method="post">
<input type="hidden" name="action" value="post_vote">
<input type="hidden" name="post_id" value="<?= (int)$p['id'] ?>">
<input type="hidden" name="value" value="-1">
<button class="btn ghost<?= $userVote === -1 ? ' is-active' : '' ?>" type="submit">Nicht hilfreich (<?= (int)$summary['unhelpful'] ?>)</button>
</form>
<?php endif; ?>
<?php if ($userId && $access && $access->supportsReports()): ?>
<form method="post">
<input type="hidden" name="action" value="report_content">
<input type="hidden" name="target_type" value="post">
<input type="hidden" name="target_id" value="<?= (int)$p['id'] ?>">
<input type="hidden" name="reason" value="Beitrag benötigt Moderation oder Prüfung.">
<button class="btn ghost" type="submit">Melden</button>
</form>
<?php endif; ?>
<?php if ($userId && $access && $access->canHighlightHelpful($community->computePoints((int)$userId))): ?>
<form method="post">
<input type="hidden" name="action" value="highlight_post">
<input type="hidden" name="post_id" value="<?= (int)$p['id'] ?>">
<input type="hidden" name="highlight" value="<?= $isHighlighted ? '0' : '1' ?>">
<button class="btn ghost" type="submit"><?= $isHighlighted ? 'Hervorhebung entfernen' : 'Als hilfreich hervorheben' ?></button>
</form>
<?php endif; ?>
<?php if ($isForumAdmin): ?>
<a class="btn ghost" href="/community_thread?id=<?= $threadId ?>&edit_post=<?= (int)$p['id'] ?>">Bearbeiten</a>
<form method="post" onsubmit="return confirm('Beitrag wirklich löschen?');">
<input type="hidden" name="action" value="post_delete">
<input type="hidden" name="post_id" value="<?= (int)$p['id'] ?>">
<button class="btn ghost" type="submit">Löschen</button>
</form>
<?php endif; ?>
</div>
</div>
</article>
<?php endforeach; ?>
<?php if (!$posts): ?>
<div class="forum-empty">Noch keine Antworten.</div>
<?php endif; ?>
</div>
<?php if ($userId): ?>
<?php if ($userRestrictions['reply_blocked']): ?>
<div class="forum-login-note">Du darfst aktuell keine Antworten schreiben. <?= !empty($userRestrictions['reason']) ? htmlspecialchars((string)$userRestrictions['reason'], ENT_QUOTES) : '' ?></div>
<?php else: ?>
<form method="post" class="forum-reply-form">
<input type="hidden" name="action" value="reply">
<div class="stack gap-6">
<label class="label" for="replyBody">Antwort</label>
<textarea id="replyBody" name="body" class="textarea" rows="4" required></textarea>
</div>
<button class="btn" type="submit">Antwort senden</button>
</form>
<?php endif; ?>
<?php else: ?>
<div class="forum-login-note">Bitte <a href="/login">einloggen</a>, um zu antworten.</div>
<?php endif; ?>
</div>
</div>
</div> </div>
</main> </main>
<?php if ($isForumAdmin): ?>
<div class="modal" id="modalThreadEdit">
<div class="panel">
<div class="head flex between center-y">
<h3 style="margin:0;">Thema bearbeiten</h3>
<button class="btn ghost" type="button" data-modal-close>✕</button>
</div>
<form method="post" class="stack gap-10" style="margin-top:12px;">
<input type="hidden" name="action" value="thread_update">
<div class="stack gap-6">
<label class="label" for="threadTitleEdit">Titel</label>
<input id="threadTitleEdit" name="title" class="input" value="<?= htmlspecialchars((string)$thread['title'], ENT_QUOTES) ?>" required>
</div>
<div class="stack gap-6">
<label class="label" for="threadBodyEdit">Text</label>
<textarea id="threadBodyEdit" name="body" class="textarea" rows="8" required><?= htmlspecialchars((string)$thread['body'], ENT_QUOTES) ?></textarea>
</div>
<div class="flex gap-12">
<button class="btn ghost" type="button" data-modal-close>Abbrechen</button>
<button class="btn" type="submit">Speichern</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<script>
document.querySelectorAll('[data-modal-open]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-modal-open');
const modal = document.getElementById(id);
if (modal) modal.classList.add('open');
});
});
document.querySelectorAll('[data-modal-close]').forEach(btn => {
btn.addEventListener('click', () => {
const modal = btn.closest('.modal');
if (modal) modal.classList.remove('open');
});
});
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.classList.remove('open');
});
});
</script>

215
partials/landing/main/home.php Normal file → Executable file
View File

@@ -3,10 +3,18 @@ $app = app();
$flash = $app->flash()->get(); $flash = $app->flash()->get();
$eventsForJs = []; $eventsForJs = [];
$threadsForJs = [];
try { try {
$pdo = $app->pdo(); $pdo = $app->pdo();
if ($pdo) { if ($pdo) {
$stmt = $pdo->prepare('SELECT id, title, teaser_public, description, city, region, zip, starts_at, allow_kids, visibility, location_label, lat, lng, created_at FROM events WHERE starts_at >= NOW() AND status != "cancelled" ORDER BY created_at DESC, starts_at ASC LIMIT 10'); $stmt = $pdo->prepare('
SELECT id, title, teaser_public, description, city, region, zip, starts_at, allow_kids, visibility, location_label, lat, lng, created_at
FROM events
WHERE starts_at >= DATE_SUB(NOW(), INTERVAL 120 DAY)
AND status != "cancelled"
ORDER BY starts_at ASC
LIMIT 60
');
$stmt->execute(); $stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
foreach ($rows as $r) { foreach ($rows as $r) {
@@ -29,9 +37,24 @@ try {
'lng' => $r['lng'] !== null ? (float)$r['lng'] : null, 'lng' => $r['lng'] !== null ? (float)$r['lng'] : null,
]; ];
} }
$communityCfg = require __DIR__ . '/../../../config/community.php';
$community = new \App\Community($pdo, $communityCfg);
$threads = $community->listThreads(8);
foreach ($threads as $thread) {
$threadsForJs[] = [
'id' => (int)$thread['id'],
'title' => (string)$thread['title'],
'body' => (string)$thread['body'],
'displayName' => (string)($thread['display_name'] ?: 'Mitglied'),
'createdAt' => (string)$thread['created_at'],
'answers' => (int)$thread['answers'],
];
}
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$eventsForJs = []; $eventsForJs = [];
$threadsForJs = [];
} }
?> ?>
<main> <main>
@@ -43,19 +66,13 @@ try {
<?php endif; ?> <?php endif; ?>
<section class="hero"> <section class="hero">
<div class="container hero__grid"> <div class="container hero__single">
<div class="hero__text"> <div class="hero__text">
<p class="eyebrow">Gemeinsam stark</p> <h1>Knüpfe Kontakte und organisiere gemeinsame Zeit mit anderen Vätern.</h1>
<h1>Treffen für Väter mit und ohne Kinder. Lokal organisiert.</h1> <p class="lede">Finde auf einen Blick Termine in deiner Umgebung, lerne andere Väter kennen, beteilige dich an Gesprächen in der Community und werde Schritt für Schritt ein fester Teil davon.</p>
<p class="lede">Finde andere Väter in deiner Nähe, plane Events oder tritt bestehenden Treffen bei. Alles an einem Ort unkompliziert und community-nah.</p> <p class="hero__copy">Als registrierter Teil der Community kannst du dein Profil mit deinen Kinderinfos pflegen, eigene Treffen organisieren und dich unkompliziert zu bestehenden Terminen dazugesellen.</p>
<div class="hero__actions"> <div class="hero__actions hero__actions--center">
<button class="btn">Kostenlos registrieren</button> <a class="btn" href="/register">Gleich kostenlos registrieren</a>
<button class="btn ghost" id="scrollToEvents">Events in deiner Nähe</button>
</div>
<div class="hero__meta">
<div class="chip inline">Events nur für Mitglieder</div>
<div class="chip inline">Kinder optional mitbringen</div>
<div class="chip inline">Echt lokale Gruppen</div>
</div> </div>
</div> </div>
</div> </div>
@@ -68,28 +85,12 @@ try {
.slider__track .event-card-small {min-width:240px; max-width:260px;} .slider__track .event-card-small {min-width:240px; max-width:260px;}
.slider__nav {min-width:44px;} .slider__nav {min-width:44px;}
</style> </style>
<section class="container section" id="events">
<div class="section__head">
<div>
<p class="eyebrow">Termine entdecken</p>
<h2>Neueste Termine</h2>
<p class="muted">Die zehn neuesten veröffentlichten Events kompakt zum Durchscrollen. Gäste sehen Basisinfos, Mitglieder erhalten alle Details.</p>
</div>
</div>
<div class="slider">
<button class="btn ghost slider__nav" type="button" data-slider-prev aria-label="Zurück"></button>
<div class="slider__viewport">
<div class="slider__track" id="eventSlider"></div>
</div>
<button class="btn ghost slider__nav" type="button" data-slider-next aria-label="Weiter"></button>
</div>
</section>
<section class="section alt" id="quicksearch"> <section class="section alt" id="quicksearch">
<div class="container"> <div class="container">
<p class="eyebrow">Schnellsuche</p> <div class="section__intro">
<h3>Passende Events finden</h3> <h2>Suche nach Treffen in deiner Nähe</h2>
<form id="quickSearchForm" class="grid grid-3" style="gap: 12px; align-items:flex-end;" action="/search" method="get"> </div>
<form id="quickSearchForm" class="grid grid-3 quicksearch-form" style="gap: 12px; align-items:flex-end;" action="/search" method="get">
<div class="stack gap-6"> <div class="stack gap-6">
<label class="label" for="qsQuery">Suchbegriff</label> <label class="label" for="qsQuery">Suchbegriff</label>
<input id="qsQuery" name="q" class="input" placeholder="Titel, Thema, Beschreibung"> <input id="qsQuery" name="q" class="input" placeholder="Titel, Thema, Beschreibung">
@@ -111,117 +112,79 @@ try {
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div> <div class="quicksearch-form__actions">
<button class="btn block" type="submit" style="width:100%;">Suchen</button> <button class="btn" type="submit">Suchen</button>
</div> </div>
</form> </form>
<p class="muted small" style="margin-top:8px;">Optional Standort ermitteln oder Ort eingeben; Umkreis bestimmt die Treffer in der Suche.</p> <p class="muted small" style="margin-top:8px;">Du kannst deinen Standort verwenden oder einen Ort eingeben und den Umkreis selbst festlegen.</p>
</div> </div>
</section> </section>
<section class="section alt" id="profil"> <section class="container section" id="events">
<div class="container split"> <div class="section__intro">
<div> <h2>Die neuesten Treffen</h2>
<p class="eyebrow">Mitgliederbereich</p> </div>
<h2>Alles für Väter Profile, Kinderinfos, Events.</h2> <div class="slider">
<p class="muted">Lege dein Profil an, erfasse deine Kids (optional) und finde passende Treffen. Später kannst du jedes Detail im Mitgliederbereich steuern.</p> <button class="btn ghost slider__nav" type="button" data-slider-prev aria-label="Zurück"></button>
<div class="grid grid-2 mt-2"> <div class="slider__viewport">
<div class="surface border rounded p-4"> <div class="slider__track" id="eventSlider"></div>
<h3 class="mt-0">Profil</h3>
<ul class="list">
<li>Anzeigename, Region, Beruf, Sprachen</li>
<li>Kontaktinfos im Mitgliederbereich hinterlegen</li>
<li>Kurzer Steckbrief für andere Väter</li>
</ul>
</div>
<div class="surface border rounded p-4">
<h3 class="mt-0">Kinder (optional)</h3>
<ul class="list">
<li>Vorname, Geschlecht, Alter oder Geburtsdatum</li>
<li>Hilft bei passenden Event-Empfehlungen</li>
<li>Du entscheidest später im Profil, was gezeigt wird</li>
</ul>
</div>
</div>
<div class="pill-row mt-2">
<span class="pill">Profil und Kids getrennt verwalten</span>
<span class="pill">Flexibel änderbar</span>
<span class="pill">Mitgliederbereich-first</span>
</div>
</div> </div>
<div class="card privacy-card"> <button class="btn ghost slider__nav" type="button" data-slider-next aria-label="Weiter"></button>
<div class="badge">Community</div> </div>
<h3>Einfach starten</h3> </section>
<ul class="list">
<li>Registriere dich kostenfrei</li> <section class="section alt" id="community-preview">
<li>Profil ausfüllen, Kinder optional hinzufügen</li> <div class="container">
<li>Events finden oder eigene Termine einstellen</li> <div class="section__intro">
</ul> <h2>Tausch dich in der Community aus</h2>
<p class="muted small">Alle Einstellungen nimmst du im Mitgliederbereich vor.</p> </div>
<div class="slider">
<button class="btn ghost slider__nav" type="button" data-thread-slider-prev aria-label="Zurück"></button>
<div class="slider__viewport">
<div class="slider__track" id="threadSlider"></div>
</div>
<button class="btn ghost slider__nav" type="button" data-thread-slider-next aria-label="Weiter"></button>
</div> </div>
</div> </div>
</section> </section>
<section class="container section" id="sicherheit"> <section class="section alt" id="mitgliederbereich">
<div class="section__head"> <div class="container">
<div> <div class="section__intro">
<p class="eyebrow">Ablauf</p> <h2>Der Mitgliederbereich ist der Ort, an dem du aus dem Überblick eine echte Nutzung machst.</h2>
<h2>So funktionierts</h2>
</div> </div>
</div> <div class="grid grid-3 mt-2">
<div class="grid grid-3"> <div class="surface border rounded p-4">
<div class="card step"> <h3 class="mt-0">Dein Profil</h3>
<div class="step__icon">1</div> <ul class="list">
<h3>Profil anlegen</h3> <li>Anzeigename, Sprachen, Interessen, kurzer Steckbrief.</li>
<p class="muted small">Papa-Daten ausfüllen, Kinder optional. Sichtbarkeit pro Bereich einstellen.</p> <li>Geschützte Kontaktdaten, du steuerst wer was sehen darf.</li>
</div> </ul>
<div class="card step"> </div>
<div class="step__icon">2</div> <div class="surface border rounded p-4">
<h3>Events finden</h3> <h3 class="mt-0">Kinder (optional)</h3>
<p class="muted small">Suche nach Thema, Alter oder Region. Gäste sehen nur Basisinfos.</p> <ul class="list">
</div> <li>Alter, Geburtsdatum oder kurze Hinweise hinterlegen, wenn es dir bei der Planung hilft.</li>
<div class="card step"> <li>Treffen besser einschätzen, wenn Aktivitäten zum Alter deiner Kinder passen sollen.</li>
<div class="step__icon">3</div> <li>Nur für dich sichtbar.</li>
<h3>Treffen planen</h3> </ul>
<p class="muted small">Als Mitglied neue Events anlegen, andere einladen, Teilnahme verwalten.</p> </div>
<div class="surface border rounded p-4">
<h3 class="mt-0">Deine Termine</h3>
<ul class="list">
<li>Veranstaltungen finden, speichern und schneller wiederfinden.</li>
<li>Eigene Treffen veröffentlichen und jederzeit aktualisieren.</li>
<li>Mit wenigen Angaben aus einer Idee einen konkreten Termin machen.</li>
</ul>
</div>
</div> </div>
</div> </div>
</section> </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>Wie starte ich?</summary>
<p>Registrieren, Profil ausfüllen, optional Kinder anlegen, dann Events entdecken oder eigene Treffen einstellen.</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>Kann ich Kinderinfos später ändern?</summary>
<p>Ja, du kannst jederzeit Daten ergänzen oder entfernen und eigene Angaben anpassen.</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> </main>
<script> <script>
window.__events = <?= json_encode($eventsForJs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>; window.__events = <?= json_encode($eventsForJs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
window.__threads = <?= json_encode($threadsForJs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
</script> </script>
<div class="modal" id="eventModal"> <div class="modal" id="eventModal">
<div class="panel"> <div class="panel">

0
partials/landing/search/search.php Normal file → Executable file
View File

10
partials/structure/layout_end.php Normal file → Executable file
View File

@@ -1,3 +1,13 @@
<footer class="site-footer">
<div class="container site-footer__inner">
<nav class="site-footer__links" aria-label="Footer">
<a href="/impressum">Impressum</a>
<a href="/ueber-uns">Über uns</a>
<button class="site-footer__link-button" type="button" data-consent-open>Cookie-Einstellungen</button>
</nav>
<p class="site-footer__copy">© Copyright <?= date('Y') ?> - Lars Gebhardt-Kusche</p>
</div>
</footer>
<?php asset_scripts('footer'); ?> <?php asset_scripts('footer'); ?>
</body> </body>
</html> </html>

99
partials/structure/layout_start.php Normal file → Executable file
View File

@@ -13,6 +13,18 @@ if (!in_array($childGender, ['male', 'female', 'mixed'], true)) {
} }
$debugEnabled = defined('APP_DEBUG') && APP_DEBUG === true; $debugEnabled = defined('APP_DEBUG') && APP_DEBUG === true;
$locationTrackingPreference = 'prompt';
if (isset($_SESSION['user_id'])) {
try {
$pdo = $app->pdo();
if ($pdo) {
$profileSettings = new \App\ProfileSettings($pdo);
$locationTrackingPreference = $profileSettings->getLocationTrackingPreference((int)$_SESSION['user_id']);
}
} catch (\Throwable) {
$locationTrackingPreference = 'prompt';
}
}
$debugFiles = []; $debugFiles = [];
if ($debugEnabled) { if ($debugEnabled) {
$debugDir = __DIR__ . '/../../debug'; $debugDir = __DIR__ . '/../../debug';
@@ -57,9 +69,94 @@ if ($debugEnabled) {
<?php asset_styles(); ?> <?php asset_styles(); ?>
<?php asset_scripts('header'); ?> <?php asset_scripts('header'); ?>
</head> </head>
<body data-auth="<?= isset($_SESSION['user_id']) ? '1' : '0' ?>" data-child-gender="<?= htmlspecialchars($childGender, ENT_QUOTES) ?>"> <body data-auth="<?= isset($_SESSION['user_id']) ? '1' : '0' ?>" data-child-gender="<?= htmlspecialchars($childGender, ENT_QUOTES) ?>" data-location-preference="<?= htmlspecialchars($locationTrackingPreference, ENT_QUOTES) ?>">
<script>
(function () {
const storageKey = 'pkt_cookie_consent';
const read = () => {
try {
const raw = localStorage.getItem(storageKey);
return raw ? JSON.parse(raw) : null;
} catch (err) {
return null;
}
};
window.PKTConsent = window.PKTConsent || {
storageKey,
get() {
return read();
},
has(category) {
if (category === 'necessary') return true;
const consent = read();
return Boolean(consent && consent[category] === true);
},
openPreferences() {
document.dispatchEvent(new CustomEvent('pkt:open-consent'));
},
};
})();
</script>
<?php tpl('matomo', 'structure'); ?> <?php tpl('matomo', 'structure'); ?>
<?php tpl('nav', 'structure'); ?> <?php tpl('nav', 'structure'); ?>
<div class="cookie-consent" id="cookieConsentBanner" hidden>
<div class="cookie-consent__inner">
<div class="cookie-consent__copy">
<strong>Cookies, Analyse und externe Dienste</strong>
<p>Notwendige Cookies für Sitzung und Login sind immer aktiv. Analyse mit Matomo sowie Karten-, Standort- und Geocoding-Funktionen werden erst nach deiner Auswahl geladen.</p>
</div>
<div class="cookie-consent__actions">
<button class="btn ghost" type="button" data-consent-necessary>Nur notwendige</button>
<button class="btn ghost" type="button" data-consent-open>Auswahl</button>
<button class="btn" type="button" data-consent-accept-all>Alle akzeptieren</button>
</div>
</div>
</div>
<div class="modal" id="cookieConsentModal">
<div class="panel">
<div class="head flex between center-y">
<h3 style="margin:0;">Cookie- und Diensteinstellungen</h3>
<button class="btn ghost" type="button" data-consent-close>✕</button>
</div>
<div class="stack gap-12" style="margin-top:12px;">
<p class="muted" style="margin:0;">Du kannst nicht notwendige Funktionen freiwillig aktivieren oder später wieder widerrufen.</p>
<div class="card">
<strong>Notwendig</strong>
<p class="muted small">Sitzung, Login und Sicherheitsfunktionen. Immer aktiv.</p>
</div>
<label class="card" style="display:block; cursor:pointer;">
<input type="checkbox" id="consentAnalytics" style="margin-right:8px;">
<strong>Analyse mit Matomo</strong>
<p class="muted small" style="margin:8px 0 0;">Reichweitenmessung und Nutzungsanalyse.</p>
</label>
<label class="card" style="display:block; cursor:pointer;">
<input type="checkbox" id="consentExternalServices" style="margin-right:8px;">
<strong>Externe Dienste, Karten und Standort</strong>
<p class="muted small" style="margin:8px 0 0;">Browser-Geolocation, lokale Standortspeicherung, Leaflet von `unpkg.com` und Geocoding/Reverse-Geocoding über OpenStreetMap Nominatim.</p>
</label>
<div class="flex gap-12" style="flex-wrap:wrap;">
<button class="btn ghost" type="button" data-consent-necessary>Nur notwendige</button>
<button class="btn" type="button" data-consent-save>Auswahl speichern</button>
</div>
</div>
</div>
</div>
<div class="modal" id="locationPreferenceModal">
<div class="panel">
<div class="head flex between center-y">
<h3 style="margin:0;">Standortfreigabe</h3>
<button class="btn ghost" type="button" data-location-pref-close>✕</button>
</div>
<div class="stack gap-12" style="margin-top:12px;">
<p class="muted" style="margin:0;">Damit wir dir passende Treffen in deiner Nähe zeigen können, dürfen wir deinen Standort verwenden. Wie soll das gehandhabt werden?</p>
<div class="stack gap-10">
<button class="btn" type="button" data-location-pref-choice="session">Nur für diesen Besuch</button>
<button class="btn ghost" type="button" data-location-pref-choice="enabled">Immer erlauben</button>
<button class="btn ghost" type="button" data-location-pref-choice="disabled">Niemals</button>
</div>
</div>
</div>
</div>
<?php if ($debugEnabled): ?> <?php if ($debugEnabled): ?>
<style> <style>

37
partials/structure/matomo.php Normal file → Executable file
View File

@@ -1,40 +1,15 @@
<?php if (!defined('MATOMO_SITE_ID')) return; ?> <?php if (!defined('MATOMO_SITE_ID')) return; ?>
<?php if (!defined('MATOMO_ENABLED') || !MATOMO_ENABLED) return; ?> <?php if (!defined('MATOMO_ENABLED') || !MATOMO_ENABLED) return; ?>
<?php <?php
$primaryDomain = app_primary_domain(); $primaryDomain = app_primary_domain();
$fakecheckDomain = app_fakecheck_domain(); $fakecheckDomain = app_fakecheck_domain();
// Build allowed domains for tracking (hosts only, no paths)
$matomoDomains = array_values(array_unique(array_filter([ $matomoDomains = array_values(array_unique(array_filter([
$primaryDomain ? '*.' . $primaryDomain : null, $primaryDomain ? '*.' . $primaryDomain : null,
($fakecheckDomain && $fakecheckDomain !== $primaryDomain) ? '*.' . $fakecheckDomain : null, ($fakecheckDomain && $fakecheckDomain !== $primaryDomain) ? '*.' . $fakecheckDomain : null,
]))); ])));
?> ?>
<script type="application/json" id="matomoConfig"><?= json_encode([
<!-- Matomo --> 'url' => rtrim(MATOMO_URL, '/') . '/',
<script> 'siteId' => (string) MATOMO_SITE_ID,
var _paq = window._paq = window._paq || []; 'domains' => $matomoDomains,
_paq.push(["setDomains", <?= json_encode($matomoDomains, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>]); ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?></script>
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u = "<?= rtrim(MATOMO_URL, '/') ?>/";
_paq.push(['setTrackerUrl', u + 'matomo.php']);
_paq.push(['setSiteId', '<?= MATOMO_SITE_ID ?>']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.async=true;
g.src=u + 'matomo.js';
s.parentNode.insertBefore(g,s);
})();
</script>
<noscript>
<p>
<img referrerpolicy="no-referrer-when-downgrade"
src="<?= rtrim(MATOMO_URL,'/') ?>/matomo.php?idsite=<?= MATOMO_SITE_ID ?>&amp;rec=1"
style="border:0;" alt="" />
</p>
</noscript>
<!-- End Matomo -->

12
partials/structure/nav.php Normal file → Executable file
View File

@@ -13,12 +13,10 @@ $isDebug = defined('APP_DEBUG') && APP_DEBUG === true;
</div> </div>
</div> </div>
<nav class="nav-links" aria-label="Hauptmenü"> <nav class="nav-links" aria-label="Hauptmenü">
<a href="/">Start</a> <a href="/">Home</a>
<a href="/#events">Events</a>
<a href="/search">Suche</a> <a href="/search">Suche</a>
<a href="/#events" data-nav-events>Termine</a>
<a href="/community">Community</a> <a href="/community">Community</a>
<a href="/#profil">Profil</a>
<a href="/#faq">FAQ</a>
</nav> </nav>
<div class="nav-actions"> <div class="nav-actions">
<?php if ($isLoggedIn): ?> <?php if ($isLoggedIn): ?>
@@ -35,12 +33,10 @@ $isDebug = defined('APP_DEBUG') && APP_DEBUG === true;
</div> </div>
</div> </div>
<div class="mobile-menu" id="mobileMenu"> <div class="mobile-menu" id="mobileMenu">
<a href="/">Start</a> <a href="/">Home</a>
<a href="/#events">Events</a>
<a href="/search">Suche</a> <a href="/search">Suche</a>
<a href="/#events" data-nav-events>Termine</a>
<a href="/community">Community</a> <a href="/community">Community</a>
<a href="/#profil">Profil</a>
<a href="/#faq">FAQ</a>
<?php if ($isLoggedIn): ?> <?php if ($isLoggedIn): ?>
<a class="btn ghost" href="/dashboard">Dashboard</a> <a class="btn ghost" href="/dashboard">Dashboard</a>
<a class="btn block" href="/logout">Logout</a> <a class="btn block" href="/logout">Logout</a>

0
public/.gitkeep Normal file → Executable file
View File

0
public/.htaccess Normal file → Executable file
View File

0
public/assets/bilder/404.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

0
public/assets/bilder/404portrait.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

0
public/assets/bilder/email/banner_emailconfirm.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

0
public/assets/bilder/email/banner_passwordreset.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

0
public/assets/bilder/email/banner_welcome.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

0
public/assets/bilder/email/logo_mail.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

0
public/assets/bilder/logo_female.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

0
public/assets/bilder/logo_male.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

0
public/assets/bilder/welcome.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

157
public/assets/css/app.css Normal file → Executable file
View File

@@ -66,19 +66,24 @@ body {
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.hero__grid { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 32px; align-items: center; } .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__single { width: 100%; }
.hero__text { width: 100%; }
.hero__text h1 { margin: 12px 0 10px 0; font-size: clamp(28px, 4vw, 42px); line-height: 1.1; text-align: center; }
.hero__text .lede { color: var(--color-muted); font-size: 17px; } .hero__text .lede { color: var(--color-muted); font-size: 17px; }
.hero__copy { margin: 14px 0 0; color: var(--color-text); }
.hero__actions { display:flex; gap: 12px; flex-wrap: wrap; margin: 18px 0; } .hero__actions { display:flex; gap: 12px; flex-wrap: wrap; margin: 18px 0; }
.hero__actions--center { justify-content: center; }
.hero__meta { display:flex; gap:8px; flex-wrap: wrap; } .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); } .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; } .lede { margin: 0; }
.muted.small { font-size: 13px; } .muted.small { font-size: 13px; }
.section { padding: 64px 0; } .section { padding: 64px 0; }
.section.alt { background: #ffffff; border-block: 1px solid var(--color-border); } .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; } .section__head { display:flex; justify-content:space-between; align-items:flex-start; gap: 16px; flex-wrap: wrap; }
.section__intro { max-width: 72ch; margin: 0 auto 22px; text-align: center; }
.section__intro h2 { margin: 0 0 10px; }
.quicksearch-form__actions { grid-column: 1 / -1; display: flex; justify-content: center; }
.split { display:grid; grid-template-columns: 1.1fr 0.9fr; gap: 24px; align-items: start; } .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; } } @media (max-width: 960px){ .split, .hero__grid { grid-template-columns: 1fr; } }
@@ -120,12 +125,129 @@ body {
.event__meta { display:flex; gap: 12px; flex-wrap: wrap; font-size: 13px; color: var(--color-muted); } .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__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; } .event__access { font-size: 12px; color: var(--color-highlight); font-weight: 700; text-transform: uppercase; letter-spacing: .5px; }
.thread-card-small { min-width: 300px; max-width: 340px; padding: 18px; }
.clamp-4 { display:-webkit-box; -webkit-line-clamp:4; -webkit-box-orient:vertical; overflow:hidden; }
.label { font-size: 13px; color: var(--color-muted); } .label { font-size: 13px; color: var(--color-muted); }
.input, .select { border-radius: var(--radius-sm); border-color: var(--color-border); background: #fff; } .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); } .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); } .cta-card { background: linear-gradient(135deg, #fdf4e0, #ffffff); }
.site-footer { border-top: 1px solid var(--color-border); background: #fff; margin-top: 40px; }
.site-footer__inner { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 24px 16px 30px; }
.site-footer__links { display: flex; gap: 18px; flex-wrap: wrap; justify-content: center; font-weight: 600; }
.site-footer__links a:hover { color: var(--color-primary); }
.site-footer__link-button { border: 0; background: transparent; padding: 0; font: inherit; color: var(--color-text); cursor: pointer; }
.site-footer__link-button:hover { color: var(--color-primary); }
.site-footer__copy { margin: 0; color: var(--color-muted); font-size: 14px; text-align: center; }
.cookie-consent { position: fixed; left: 16px; right: 16px; bottom: 16px; z-index: 220; }
.cookie-consent[hidden] { display: none; }
.cookie-consent__inner { max-width: 1120px; margin: 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; align-items: center; padding: 18px 20px; background: rgba(255,255,255,0.98); border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: 0 18px 40px rgba(0,0,0,0.16); backdrop-filter: blur(10px); }
.cookie-consent__copy p { margin: 6px 0 0; color: var(--color-muted); }
.cookie-consent__actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.legal-page { min-height: 40vh; }
.content-card { background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 24px; box-shadow: var(--shadow-card); }
.content-card--narrow { max-width: 860px; margin: 0 auto; }
.content-card p + p { margin-top: 16px; }
.section__intro--wide { max-width: 980px; }
.page-copy { display: grid; gap: 18px; }
.page-copy--wide { max-width: 980px; margin: 0 auto; }
.page-copy p { margin: 0; font-size: 18px; line-height: 1.7; color: var(--color-text); }
.section__intro p { margin: 0; font-size: 18px; line-height: 1.7; color: var(--color-text); text-align: left; }
.section__intro p + p { margin-top: 18px; }
.forum-breadcrumbs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; color: var(--color-muted); font-size: 14px; margin-bottom: 16px; }
.forum-breadcrumbs a:hover { color: var(--color-primary); }
.forum-hero { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; padding: 26px 28px; background: linear-gradient(135deg, #fffdf8, #f8f2e7); border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-card); }
.forum-hero h1 { margin: 6px 0 8px; }
.forum-hero__copy { margin: 0; max-width: 70ch; color: var(--color-muted); font-size: 16px; }
.forum-hero__actions { display: flex; align-items: center; gap: 12px; }
.forum-hero__actions--stack { flex-direction: column; align-items: stretch; min-width: min(100%, 360px); }
.forum-hero__cta { display: flex; justify-content: flex-end; }
.forum-stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 14px; margin: 18px 0 24px; }
.forum-stats--compact { grid-template-columns: repeat(2, minmax(0,1fr)); max-width: 540px; }
.forum-stat { padding: 18px 20px; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-md); box-shadow: var(--shadow-card); }
.forum-stat strong { display: block; font-size: 28px; line-height: 1.1; }
.forum-stat__label { display: block; margin-bottom: 8px; color: var(--color-muted); font-size: 13px; text-transform: uppercase; letter-spacing: .4px; }
.forum-category-list { display: grid; gap: 22px; margin-top: 18px; }
.forum-category-card { background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden; }
.forum-category-card__title { padding: 16px 24px; border-bottom: 1px solid var(--color-border); background: linear-gradient(90deg, #fffdf7, #f5eee2); font-size: 28px; font-weight: 700; color: var(--color-primary); }
.forum-board-grid { display: grid; }
.forum-board-row { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 18px; padding: 22px 24px; border-top: 1px solid var(--color-border); align-items: start; }
.forum-board-row:first-child { border-top: 0; }
.forum-board-row__main { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 14px; }
.forum-board-row__main h3 { margin: 0 0 6px; font-size: 18px; }
.forum-board-row__main h3 a:hover { color: var(--color-primary); }
.forum-board-row__main p { margin: 0; color: var(--color-muted); line-height: 1.5; }
.forum-board-row__latest { display: grid; gap: 4px; align-content: start; }
.forum-board-row__latest strong { font-size: 16px; }
.forum-board-row__latest a:hover { color: var(--color-primary); }
.forum-board { background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden; }
.forum-board__head { display: flex; justify-content: space-between; gap: 20px; align-items: end; padding: 22px 24px; border-bottom: 1px solid var(--color-border); background: #fffdfa; }
.forum-board__head h2 { margin: 0 0 6px; }
.forum-shell { display: grid; grid-template-columns: 290px minmax(0, 1fr); gap: 24px; margin-top: 24px; align-items: start; }
.forum-shell__main { min-width: 0; }
.forum-sidebar { position: sticky; top: 104px; }
.forum-sidebar__inner { background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden; }
.forum-sidebar__head { padding: 20px 20px 16px; border-bottom: 1px solid var(--color-border); background: linear-gradient(180deg, #fffdfa, #f8f4ec); }
.forum-sidebar__head h2 { margin: 10px 0 0; font-size: 22px; }
.forum-sidebar__tree { padding: 14px; display: grid; gap: 14px; }
.forum-sidebar__group h3 { margin: 0 0 10px; font-size: 15px; color: var(--color-primary); }
.forum-sidebar__group ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.forum-sidebar__group a { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 12px; color: var(--color-text); transition: background .15s ease, color .15s ease; }
.forum-sidebar__group a:hover { background: #f8f4ec; color: var(--color-primary); }
.forum-sidebar__group a.is-active { background: var(--color-primary); color: var(--color-primary-contrast); }
.forum-sidebar__group small { font-size: 12px; opacity: .8; }
.forum-search { display: grid; gap: 8px; min-width: min(100%, 360px); }
.forum-search--hero { width: 100%; }
.forum-search__row { display: flex; gap: 10px; }
.forum-list__header { display: grid; grid-template-columns: minmax(0, 1fr) 120px 220px; gap: 16px; padding: 14px 24px; background: #f8f4ec; color: var(--color-muted); font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .35px; }
.forum-row { display: grid; grid-template-columns: minmax(0, 1fr) 120px 220px; gap: 16px; padding: 20px 24px; border-top: 1px solid var(--color-border); align-items: start; }
.forum-row__topic { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 14px; }
.forum-row__icon { width: 40px; height: 40px; border-radius: 12px; display: flex; align-items: center; justify-content: center; background: var(--color-accent-soft); font-size: 18px; }
.forum-row__topic h3 { margin: 0 0 8px; font-size: 20px; line-height: 1.2; }
.forum-row__topic h3 a:hover { color: var(--color-primary); }
.forum-row__meta { display: flex; flex-wrap: wrap; gap: 8px 12px; color: var(--color-muted); font-size: 13px; margin-bottom: 8px; }
.forum-row__topic p { margin: 0; color: var(--color-text); line-height: 1.55; }
.forum-row__count, .forum-row__activity { display: grid; gap: 4px; align-content: start; }
.forum-row__count strong, .forum-row__activity strong { font-size: 18px; line-height: 1.1; }
.forum-row__count span, .forum-row__activity span { color: var(--color-muted); font-size: 13px; }
.forum-empty { padding: 28px 24px; color: var(--color-muted); }
.forum-thread-head { display: flex; justify-content: space-between; gap: 20px; align-items: flex-start; margin-bottom: 18px; }
.forum-thread-head h1 { margin: 0 0 8px; }
.forum-thread-head__meta { margin: 0; color: var(--color-muted); }
.forum-thread-head__meta-box { min-width: 120px; display: grid; gap: 4px; padding: 16px 18px; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-md); text-align: center; box-shadow: var(--shadow-card); }
.forum-thread-head__meta-box strong { font-size: 28px; line-height: 1; }
.forum-thread-head__meta-box span { color: var(--color-muted); font-size: 13px; text-transform: uppercase; letter-spacing: .35px; }
.forum-post { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 0; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden; }
.forum-post + .forum-post { margin-top: 14px; }
.forum-post--lead { margin-bottom: 24px; }
.forum-post__author { display: grid; gap: 8px; align-content: start; padding: 22px 18px; background: #f8f4ec; border-right: 1px solid var(--color-border); }
.forum-post__avatar { width: 52px; height: 52px; border-radius: 16px; display: flex; align-items: center; justify-content: center; background: var(--color-primary); color: #fff; font-size: 22px; font-weight: 700; }
.forum-post__author strong { font-size: 17px; }
.forum-post__author span { color: var(--color-muted); font-size: 14px; }
.forum-post__body { padding: 20px 24px; }
.forum-post__head { display: flex; justify-content: space-between; gap: 12px; color: var(--color-muted); font-size: 14px; margin-bottom: 14px; }
.forum-post__content { font-size: 17px; line-height: 1.7; color: var(--color-text); }
.forum-post--highlighted { border-color: #d6b46f; box-shadow: 0 0 0 1px rgba(214,180,111,0.22), var(--shadow-card); }
.forum-highlight-badge { display: inline-flex; align-items: center; padding: 4px 8px; border-radius: 999px; background: var(--color-accent-soft); color: var(--color-highlight); font-size: 12px; font-weight: 700; }
.forum-post__toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
.forum-post__toolbar .btn.is-active { background: var(--color-primary); color: var(--color-primary-contrast); border-color: var(--color-primary); }
.forum-post__admin { display: grid; gap: 8px; margin-top: 10px; }
.forum-inline-report { margin-top: 16px; }
.forum-admin-mini-form { margin-top: 10px; }
.forum-replies-head { display: flex; justify-content: space-between; gap: 16px; align-items: center; margin: 0 0 14px; }
.forum-replies-head h2 { margin: 0; }
.forum-replies-head span { color: var(--color-muted); }
.forum-replies { display: grid; gap: 0; }
.forum-reply-form { display: grid; gap: 14px; margin-top: 18px; padding: 22px 24px; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-card); }
.forum-login-note { margin-top: 18px; color: var(--color-muted); }
.forum-admin-grid { display: grid; gap: 22px; margin-top: 24px; }
.forum-admin-list { display: grid; }
.forum-admin-item { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; padding: 20px 24px; border-top: 1px solid var(--color-border); }
.forum-admin-item:first-child { border-top: 0; }
.forum-admin-item p { margin: 8px 0 0; }
.forum-admin-item__actions { display: grid; gap: 10px; }
.forum-hero--compact { padding-bottom: 8px; }
.flex { display:flex; } .flex { display:flex; }
.between { justify-content: space-between; } .between { justify-content: space-between; }
@@ -133,10 +255,39 @@ body {
.gap-12 { gap: 12px; } .gap-12 { gap: 12px; }
.stack { display:flex; flex-direction: column; } .stack { display:flex; flex-direction: column; }
@media (max-width: 980px){
.forum-board-row,
.forum-admin-item,
.forum-list__header,
.forum-row { grid-template-columns: minmax(0, 1fr); }
.forum-shell { grid-template-columns: 1fr; }
.forum-sidebar { position: static; }
.forum-row__count,
.forum-row__activity { grid-auto-flow: column; justify-content: start; gap: 10px; align-items: baseline; }
.forum-post { grid-template-columns: 1fr; }
.forum-post__author { border-right: 0; border-bottom: 1px solid var(--color-border); }
}
@media (max-width: 720px){ @media (max-width: 720px){
.nav-row { padding: 12px 0; } .nav-row { padding: 12px 0; }
.hero { padding: 40px 0; } .hero { padding: 40px 0; }
.section { padding: 48px 0; } .section { padding: 48px 0; }
.forum-hero,
.forum-board__head,
.forum-thread-head { grid-template-columns: 1fr; display: grid; }
.forum-stats,
.forum-stats--compact { grid-template-columns: 1fr; }
.forum-search__row { flex-direction: column; }
.forum-category-card__title,
.forum-row,
.forum-list__header,
.forum-board__head,
.forum-board-row,
.forum-admin-item { padding-left: 16px; padding-right: 16px; }
.forum-post__body,
.forum-reply-form { padding: 18px 16px; }
.cookie-consent__inner { grid-template-columns: 1fr; }
.cookie-consent__actions { justify-content: stretch; }
} }
/* Auth & Dashboard */ /* Auth & Dashboard */

0
public/assets/fonts/KidsHandwriting-Regular.ttf Normal file → Executable file
View File

0
public/assets/fonts/KidsHandwriting-Regular.woff Normal file → Executable file
View File

0
public/assets/fonts/KidsHandwriting-Regular.woff2 Normal file → Executable file
View File

445
public/assets/js/app.js Normal file → Executable file
View File

@@ -2,6 +2,7 @@ document.addEventListener('DOMContentLoaded', () => {
const body = document.body; const body = document.body;
const isLoggedIn = body.dataset.auth === '1'; const isLoggedIn = body.dataset.auth === '1';
const childGender = body.dataset.childGender || ''; const childGender = body.dataset.childGender || '';
const locationPreference = body.dataset.locationPreference || 'prompt';
const header = document.querySelector('.site-header'); const header = document.querySelector('.site-header');
const headerSentinel = document.getElementById('headerSentinel'); const headerSentinel = document.getElementById('headerSentinel');
@@ -51,12 +52,21 @@ document.addEventListener('DOMContentLoaded', () => {
// Events from backend (injected on page); fallback to empty // Events from backend (injected on page); fallback to empty
const events = Array.isArray(window.__events) ? window.__events : []; const events = Array.isArray(window.__events) ? window.__events : [];
const threads = Array.isArray(window.__threads) ? window.__threads : [];
const EVENTS_RADIUS_KM = 20;
const LOCATION_STORAGE_KEY = 'pkt_user_location';
const LOCATION_SESSION_STORAGE_KEY = 'pkt_user_location_session';
const LOCATION_SESSION_PREFERENCE_KEY = 'pkt_user_location_session_preference';
const el = { const el = {
sliderTrack: document.getElementById('eventSlider'), sliderTrack: document.getElementById('eventSlider'),
sliderViewport: document.querySelector('.slider__viewport'), sliderViewport: document.querySelector('.slider__viewport'),
sliderPrev: document.querySelector('[data-slider-prev]'), sliderPrev: document.querySelector('[data-slider-prev]'),
sliderNext: document.querySelector('[data-slider-next]'), sliderNext: document.querySelector('[data-slider-next]'),
threadSliderTrack: document.getElementById('threadSlider'),
threadSliderViewport: document.getElementById('threadSlider')?.closest('.slider__viewport') || null,
threadSliderPrev: document.querySelector('[data-thread-slider-prev]'),
threadSliderNext: document.querySelector('[data-thread-slider-next]'),
modal: document.getElementById('eventModal'), modal: document.getElementById('eventModal'),
modalBody: document.getElementById('eventModalBody'), modalBody: document.getElementById('eventModalBody'),
modalTitle: document.getElementById('eventModalTitle'), modalTitle: document.getElementById('eventModalTitle'),
@@ -66,43 +76,211 @@ document.addEventListener('DOMContentLoaded', () => {
quickLat: document.getElementById('qsLat'), quickLat: document.getElementById('qsLat'),
quickLng: document.getElementById('qsLng'), quickLng: document.getElementById('qsLng'),
quickGeo: document.getElementById('quickGeo'), quickGeo: document.getElementById('quickGeo'),
eventsSection: document.getElementById('events'),
locationPreferenceModal: document.getElementById('locationPreferenceModal'),
}; };
let effectiveLocationPreference = locationPreference;
const fmtDate = (iso) => { const fmtDate = (iso) => {
const d = new Date(iso); const d = new Date(iso);
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}; };
const now = () => new Date();
const setCookie = (name, value, days = 30) => {
const expires = new Date(Date.now() + (days * 86400000)).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
};
const parseCookie = (name) => {
const prefix = `${name}=`;
return document.cookie
.split(';')
.map(item => item.trim())
.find(item => item.startsWith(prefix))
?.slice(prefix.length) || '';
};
const persistLocation = (location, mode = 'persistent') => {
if (!location || !Number.isFinite(location.lat) || !Number.isFinite(location.lng)) return;
const payload = {
lat: Number(location.lat.toFixed(6)),
lng: Number(location.lng.toFixed(6)),
label: location.label || 'Mein Standort',
savedAt: new Date().toISOString(),
};
if (mode === 'session') {
try {
window.sessionStorage.setItem(LOCATION_SESSION_STORAGE_KEY, JSON.stringify(payload));
} catch (err) {
// ignore
}
} else {
try {
window.localStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(payload));
} catch (err) {
// ignore
}
setCookie('pkt_user_location', JSON.stringify(payload), 30);
}
if (el.quickLat) el.quickLat.value = payload.lat.toFixed(6);
if (el.quickLng) el.quickLng.value = payload.lng.toFixed(6);
if (el.quickLoc && (!el.quickLoc.value || el.quickLoc.value === 'Mein Standort')) {
el.quickLoc.value = payload.label;
}
};
const clearStoredLocation = () => {
try {
window.localStorage.removeItem(LOCATION_STORAGE_KEY);
} catch (err) {
// ignore
}
try {
window.sessionStorage.removeItem(LOCATION_SESSION_STORAGE_KEY);
} catch (err) {
// ignore
}
document.cookie = 'pkt_user_location=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax';
if (el.quickLat) el.quickLat.value = '';
if (el.quickLng) el.quickLng.value = '';
if (el.quickLoc) el.quickLoc.value = '';
};
const getStoredLocation = () => {
const candidates = [];
try {
const session = window.sessionStorage.getItem(LOCATION_SESSION_STORAGE_KEY);
if (session) candidates.push(session);
} catch (err) {
// ignore
}
try {
const local = window.localStorage.getItem(LOCATION_STORAGE_KEY);
if (local) candidates.push(local);
} catch (err) {
// ignore
}
const cookieValue = parseCookie('pkt_user_location');
if (cookieValue) candidates.push(decodeURIComponent(cookieValue));
for (const entry of candidates) {
try {
const parsed = JSON.parse(entry);
if (parsed && Number.isFinite(parsed.lat) && Number.isFinite(parsed.lng)) {
return {
lat: Number(parsed.lat),
lng: Number(parsed.lng),
label: parsed.label || 'Mein Standort',
};
}
} catch (err) {
// ignore invalid payloads
}
}
return null;
};
const haversineKm = (lat1, lng1, lat2, lng2) => {
const toRad = (value) => value * Math.PI / 180;
const earthRadiusKm = 6371;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const smallTag = (label) => `<span class="badge">${label}</span>`; const smallTag = (label) => `<span class="badge">${label}</span>`;
const getRankedEvents = (sourceEvents, location) => {
const normalized = sourceEvents.map(item => {
const startsAtDate = new Date(item.startsAt);
const isFuture = startsAtDate >= now();
const hasGeo = Number.isFinite(item.lat) && Number.isFinite(item.lng);
const distanceKm = (location && hasGeo)
? haversineKm(location.lat, location.lng, item.lat, item.lng)
: null;
return {
...item,
startsAtDate,
isFuture,
distanceKm,
};
});
if (!location) {
return normalized
.filter(item => item.isFuture)
.sort((a, b) => a.startsAtDate - b.startsAtDate)
.slice(0, 10);
}
const nearby = normalized.filter(item => item.distanceKm !== null && item.distanceKm <= EVENTS_RADIUS_KM);
const byDistanceThenDate = (a, b) => {
if (a.distanceKm !== b.distanceKm) return a.distanceKm - b.distanceKm;
return a.startsAtDate - b.startsAtDate;
};
const upcomingNearby = nearby
.filter(item => item.isFuture)
.sort(byDistanceThenDate);
if (upcomingNearby.length) {
return upcomingNearby.slice(0, 10);
}
const missedNearby = nearby
.filter(item => !item.isFuture)
.sort((a, b) => {
if (a.distanceKm !== b.distanceKm) return a.distanceKm - b.distanceKm;
return b.startsAtDate - a.startsAtDate;
})
.map(item => ({ ...item, missed: true }));
if (missedNearby.length) {
return missedNearby.slice(0, 10);
}
return normalized
.filter(item => item.isFuture)
.sort((a, b) => a.startsAtDate - b.startsAtDate)
.slice(0, 10);
};
const renderCard = (item) => { const renderCard = (item) => {
const guest = !isLoggedIn; const guest = !isLoggedIn;
const kids = item.allowKids ? 'Mit Kindern' : 'Ohne Kinder'; const kids = item.allowKids ? 'Mit Kindern' : 'Ohne Kinder';
const distanceTag = Number.isFinite(item.distanceKm) ? smallTag(`${item.distanceKm.toFixed(1)} km`) : '';
const missedTag = item.missed ? smallTag('Das hast du verpasst') : '';
return ` return `
<article class="card event-card-small"> <article class="card event-card-small">
<div class="muted small">${fmtDate(item.startsAt)}</div> <div class="muted small">${fmtDate(item.startsAt)}</div>
<h3>${item.title}</h3> <h3>${item.title}</h3>
<p class="muted">${item.teaser}</p> <p class="muted">${item.teaser}</p>
<div class="muted small">📍 ${item.city || item.region || ''}</div> <div class="muted small">📍 ${item.city || item.region || ''}</div>
<div class="event__tags">${smallTag(kids)} ${item.visibility === 'public' ? smallTag('Öffentlich') : smallTag('Mitglieder')}</div> <div class="event__tags">${smallTag(kids)} ${item.visibility === 'public' ? smallTag('Öffentlich') : smallTag('Mitglieder')} ${distanceTag} ${missedTag}</div>
<div class="flex gap-8" style="margin-top:8px;"> <div class="flex gap-8" style="margin-top:8px;">
<button class="btn ghost" data-event-detail="${item.id}">Details</button> <button class="btn ghost" data-event-detail="${item.id}">Details</button>
${guest ? '' : '<button class="btn">Teilnehmen</button>'} ${guest || item.missed ? '' : '<button class="btn">Teilnehmen</button>'}
</div> </div>
</article>`; </article>`;
}; };
const renderSlider = () => { const renderSlider = (location = null) => {
if (!el.sliderTrack) return; if (!el.sliderTrack) return;
if (!events.length) { const rankedEvents = getRankedEvents(events, location);
if (!rankedEvents.length) {
el.sliderTrack.innerHTML = '<p class="muted">Keine Events vorhanden.</p>'; el.sliderTrack.innerHTML = '<p class="muted">Keine Events vorhanden.</p>';
return; return;
} }
el.sliderTrack.innerHTML = events.slice(0, 10).map(renderCard).join(''); el.sliderTrack.innerHTML = rankedEvents.map(renderCard).join('');
el.sliderTrack.querySelectorAll('[data-event-detail]').forEach(btn => { el.sliderTrack.querySelectorAll('[data-event-detail]').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const id = parseInt(btn.getAttribute('data-event-detail'), 10); const id = parseInt(btn.getAttribute('data-event-detail'), 10);
const ev = events.find(e => e.id === id); const ev = rankedEvents.find(e => e.id === id);
if (!ev || !el.modal || !el.modalBody || !el.modalTitle) return; if (!ev || !el.modal || !el.modalBody || !el.modalTitle) return;
el.modalTitle.textContent = ev.title; el.modalTitle.textContent = ev.title;
el.modalBody.innerHTML = ` el.modalBody.innerHTML = `
@@ -110,6 +288,8 @@ document.addEventListener('DOMContentLoaded', () => {
<p class="muted">${ev.teaser}</p> <p class="muted">${ev.teaser}</p>
<p>${ev.description}</p> <p>${ev.description}</p>
${ev.locationLabel ? `<p><strong>Ort:</strong> ${ev.locationLabel}</p>` : ''} ${ev.locationLabel ? `<p><strong>Ort:</strong> ${ev.locationLabel}</p>` : ''}
${Number.isFinite(ev.distanceKm) ? `<p><strong>Entfernung:</strong> ${ev.distanceKm.toFixed(1)} km</p>` : ''}
${ev.missed ? '<p><strong>Hinweis:</strong> Das hast du verpasst.</p>' : ''}
<p><strong>Kinder:</strong> ${ev.allowKids ? 'Mit Kindern' : 'Ohne Kinder'}</p> <p><strong>Kinder:</strong> ${ev.allowKids ? 'Mit Kindern' : 'Ohne Kinder'}</p>
<p><strong>Sichtbarkeit:</strong> ${ev.visibility === 'public' ? 'Öffentlich' : 'Nur Mitglieder'}</p> <p><strong>Sichtbarkeit:</strong> ${ev.visibility === 'public' ? 'Öffentlich' : 'Nur Mitglieder'}</p>
`; `;
@@ -118,14 +298,206 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}; };
const scrollSlider = (dir) => { const renderThreadCard = (item) => {
if (!el.sliderViewport) return; const preview = item.body.length > 170 ? `${item.body.slice(0, 170)}` : item.body;
const amount = dir === 'next' ? 320 : -320; return `
el.sliderViewport.scrollBy({ left: amount, behavior: 'smooth' }); <article class="card thread-card-small">
<div class="muted small">${fmtDate(item.createdAt)}</div>
<h3>${item.title}</h3>
<p class="muted clamp-4">${preview}</p>
<div class="event__meta">
<span>${item.displayName}</span>
<span>Antworten: ${item.answers}</span>
</div>
<div class="flex gap-8" style="margin-top:8px;">
<a class="btn ghost" href="/community_thread?id=${item.id}">Beitrag öffnen</a>
</div>
</article>`;
}; };
el.sliderPrev?.addEventListener('click', () => scrollSlider('prev')); const renderThreadSlider = () => {
el.sliderNext?.addEventListener('click', () => scrollSlider('next')); if (!el.threadSliderTrack) return;
if (!threads.length) {
el.threadSliderTrack.innerHTML = '<p class="muted">Noch keine Community-Beiträge vorhanden.</p>';
return;
}
el.threadSliderTrack.innerHTML = threads.map(renderThreadCard).join('');
};
const scrollSlider = (viewport, dir) => {
if (!viewport) return;
const amount = dir === 'next' ? 320 : -320;
viewport.scrollBy({ left: amount, behavior: 'smooth' });
};
el.sliderPrev?.addEventListener('click', () => scrollSlider(el.sliderViewport, 'prev'));
el.sliderNext?.addEventListener('click', () => scrollSlider(el.sliderViewport, 'next'));
el.threadSliderPrev?.addEventListener('click', () => scrollSlider(el.threadSliderViewport, 'prev'));
el.threadSliderNext?.addEventListener('click', () => scrollSlider(el.threadSliderViewport, 'next'));
const applyStoredLocationToForm = () => {
const stored = getStoredLocation();
if (!stored) return null;
if (el.quickLat) el.quickLat.value = stored.lat.toFixed(6);
if (el.quickLng) el.quickLng.value = stored.lng.toFixed(6);
if (el.quickLoc && !el.quickLoc.value) el.quickLoc.value = stored.label || 'Mein Standort';
return stored;
};
const requestLocation = (options = {}) => new Promise((resolve) => {
if (!navigator.geolocation) {
resolve(null);
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const location = {
lat: pos.coords.latitude,
lng: pos.coords.longitude,
label: 'Mein Standort',
};
persistLocation(location, options.persistMode || 'persistent');
resolve(location);
},
() => resolve(null),
{
enableHighAccuracy: true,
timeout: options.timeout || 10000,
maximumAge: options.maximumAge || 300000,
}
);
});
const closeLocationPreferenceModal = () => {
el.locationPreferenceModal?.classList.remove('open');
};
document.querySelectorAll('[data-location-pref-close]').forEach(btn => {
btn.addEventListener('click', closeLocationPreferenceModal);
});
el.locationPreferenceModal?.addEventListener('click', (event) => {
if (event.target === el.locationPreferenceModal) {
closeLocationPreferenceModal();
}
});
const saveLocationPreference = async (preference) => {
effectiveLocationPreference = preference;
if (!isLoggedIn) {
try {
window.localStorage.setItem('pkt_location_preference_guest', preference);
} catch (err) {
// ignore
}
return true;
}
try {
const body = new URLSearchParams({ preference });
const response = await fetch('/api/location-preference', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
body: body.toString(),
});
return response.ok;
} catch (err) {
return false;
}
};
const getSessionPreference = () => {
try {
return window.sessionStorage.getItem(LOCATION_SESSION_PREFERENCE_KEY);
} catch (err) {
return null;
}
};
const setSessionPreference = (value) => {
try {
if (!value) {
window.sessionStorage.removeItem(LOCATION_SESSION_PREFERENCE_KEY);
} else {
window.sessionStorage.setItem(LOCATION_SESSION_PREFERENCE_KEY, value);
}
} catch (err) {
// ignore
}
};
const resolvePromptChoice = () => new Promise((resolve) => {
if (!el.locationPreferenceModal) {
resolve('disabled');
return;
}
const buttons = el.locationPreferenceModal.querySelectorAll('[data-location-pref-choice]');
const cleanup = () => {
buttons.forEach(button => button.removeEventListener('click', handleClick));
document.removeEventListener('keydown', handleKeydown);
};
const finish = (value) => {
cleanup();
closeLocationPreferenceModal();
resolve(value);
};
const handleClick = (event) => {
const value = event.currentTarget.getAttribute('data-location-pref-choice') || 'disabled';
finish(value);
};
const handleKeydown = (event) => {
if (event.key === 'Escape') {
finish('disabled');
}
};
buttons.forEach(button => button.addEventListener('click', handleClick));
document.addEventListener('keydown', handleKeydown);
el.locationPreferenceModal.classList.add('open');
});
const ensureEventsLocation = async () => {
const sessionPreference = getSessionPreference();
if (effectiveLocationPreference === 'disabled') {
clearStoredLocation();
renderSlider(null);
return null;
}
const stored = applyStoredLocationToForm();
if (stored && effectiveLocationPreference !== 'enabled' && sessionPreference !== 'session') {
renderSlider(stored);
return stored;
}
if (effectiveLocationPreference === 'prompt') {
const choice = sessionPreference || await resolvePromptChoice();
if (choice === 'session') {
setSessionPreference('session');
const location = await requestLocation({ persistMode: 'session' });
renderSlider(location || stored);
return location;
}
if (choice === 'disabled') {
await saveLocationPreference('disabled');
clearStoredLocation();
renderSlider(null);
return null;
}
if (choice === 'enabled') {
await saveLocationPreference('enabled');
effectiveLocationPreference = 'enabled';
}
}
const location = await requestLocation({
persistMode: effectiveLocationPreference === 'enabled' ? 'persistent' : 'session',
});
renderSlider(location || stored);
return location;
};
// Quick search with geocoding // Quick search with geocoding
const geocode = async (query) => { const geocode = async (query) => {
@@ -145,11 +517,12 @@ document.addEventListener('DOMContentLoaded', () => {
} }
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(pos) => { (pos) => {
if (el.quickLat && el.quickLng) { persistLocation({
el.quickLat.value = pos.coords.latitude.toFixed(6); lat: pos.coords.latitude,
el.quickLng.value = pos.coords.longitude.toFixed(6); lng: pos.coords.longitude,
if (el.quickLoc) el.quickLoc.value = 'Mein Standort'; label: 'Mein Standort',
} }, effectiveLocationPreference === 'enabled' ? 'persistent' : 'session');
renderSlider(getStoredLocation());
}, },
() => alert('Standort konnte nicht ermittelt werden.') () => alert('Standort konnte nicht ermittelt werden.')
); );
@@ -182,5 +555,41 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
el.modal?.addEventListener('click', (e) => { if (e.target === el.modal) el.modal.classList.remove('open'); }); el.modal?.addEventListener('click', (e) => { if (e.target === el.modal) el.modal.classList.remove('open'); });
renderSlider(); document.querySelectorAll('[data-nav-events]').forEach(link => {
link.addEventListener('click', async () => {
if (window.location.pathname === '/' && window.location.hash === '#events') {
await ensureEventsLocation();
}
});
});
try {
if (!isLoggedIn) {
const guestPreference = window.localStorage.getItem('pkt_location_preference_guest');
if (guestPreference && ['disabled', 'prompt', 'enabled'].includes(guestPreference)) {
effectiveLocationPreference = guestPreference;
}
}
} catch (err) {
// ignore
}
const initialLocation = effectiveLocationPreference === 'disabled' ? null : applyStoredLocationToForm();
if (effectiveLocationPreference === 'disabled') {
clearStoredLocation();
}
renderSlider(initialLocation);
renderThreadSlider();
if (effectiveLocationPreference === 'enabled' && window.location.pathname === '/') {
ensureEventsLocation();
} else if (window.location.hash === '#events' || window.location.hash === '#quicksearch') {
ensureEventsLocation();
}
window.addEventListener('hashchange', () => {
if (window.location.hash === '#events' || window.location.hash === '#quicksearch') {
ensureEventsLocation();
}
});
}); });

10
public/index.php Normal file → Executable file
View File

@@ -12,9 +12,14 @@ $isRetoolPath = ($uriPath === 'retool' || str_starts_with($uriPath, 'retool/'));
if (defined('APP_ENV') && APP_ENV === 'staging' && !$isRetoolPath) { if (defined('APP_ENV') && APP_ENV === 'staging' && !$isRetoolPath) {
$authUser = getenv('STAGING_AUTH_USER') ?: 'staging'; $authUser = getenv('STAGING_AUTH_USER') ?: 'staging';
$authPass = getenv('STAGING_AUTH_PASS') ?: 'staging123'; $authPass = getenv('STAGING_AUTH_PASS') ?: 'staging123';
$remoteUser = $_SERVER['REMOTE_USER'] ?? null;
$user = $_SERVER['PHP_AUTH_USER'] ?? null; $user = $_SERVER['PHP_AUTH_USER'] ?? null;
$pass = $_SERVER['PHP_AUTH_PW'] ?? null; $pass = $_SERVER['PHP_AUTH_PW'] ?? null;
if ($user !== $authUser || $pass !== $authPass) {
// If Apache already authenticated the request via .htaccess, trust that layer.
$hasUpstreamBasicAuth = is_string($remoteUser) && $remoteUser !== '';
if (!$hasUpstreamBasicAuth && ($user !== $authUser || $pass !== $authPass)) {
header('WWW-Authenticate: Basic realm="Staging"'); header('WWW-Authenticate: Basic realm="Staging"');
header('HTTP/1.0 401 Unauthorized'); header('HTTP/1.0 401 Unauthorized');
echo 'Unauthorized'; echo 'Unauthorized';
@@ -63,6 +68,9 @@ $targetReal = realpath($target);
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/retool'))) { if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/retool'))) {
$skipLayout = true; $skipLayout = true;
} }
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/api'))) {
$skipLayout = true;
}
// ------------------------------------ // ------------------------------------
// Ausgabe // Ausgabe

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'method_not_allowed']);
return;
}
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'unauthorized']);
return;
}
$preference = (string)($_POST['preference'] ?? '');
if (!in_array($preference, ['disabled', 'prompt', 'enabled'], true)) {
http_response_code(422);
echo json_encode(['ok' => false, 'error' => 'invalid_preference']);
return;
}
try {
$pdo = app()->pdo();
if (!$pdo) {
throw new RuntimeException('db_unavailable');
}
$settings = new \App\ProfileSettings($pdo);
$settings->updateLocationTrackingPreference((int)$_SESSION['user_id'], $preference);
echo json_encode(['ok' => true, 'preference' => $preference]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'save_failed']);
}

View File

@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
tpl('community-admin', 'landing', 'account');

0
public/page/community.php Normal file → Executable file
View File

0
public/page/community_thread.php Normal file → Executable file
View File

0
public/page/dashboard.php Normal file → Executable file
View File

0
public/page/debug.php Normal file → Executable file
View File

26
public/page/impressum.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
?>
<main class="container section legal-page">
<h1>Impressum</h1>
<p>
Lars Gebhardt-Kusche<br>
Hammerstraße 47B<br>
14167 Berlin
</p>
<h2>Kontakt</h2>
<p>
Telefon: +49 (171) 33 10 538<br>
E-Mail: <a href="mailto:rechtliches@papa-kind-treff.info">rechtliches@papa-kind-treff.info</a>
</p>
<h2>Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</h2>
<p>
Lars Gebhardt-Kusche<br>
Hammerstraße 47B<br>
14167 Berlin
</p>
</main>

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

0
public/page/login.php Normal file → Executable file
View File

0
public/page/logout.php Normal file → Executable file
View File

0
public/page/register.php Normal file → Executable file
View File

0
public/page/reset.php Normal file → Executable file
View File

0
public/page/retool/emailtemplate_bridge.php Normal file → Executable file
View File

0
public/page/retool/emailtemplate_sender.php Normal file → Executable file
View File

0
public/page/search.php Normal file → Executable file
View File

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
if (!defined('APP_ENV') || APP_ENV !== 'staging') {
http_response_code(404);
exit;
}
$app = app();
$pdo = $app->pdo();
$users = [];
$error = '';
try {
if (!$pdo) {
throw new RuntimeException('Keine Datenbankverbindung verfügbar.');
}
$hasRolesTable = false;
try {
$stmt = $pdo->query("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'user_roles' LIMIT 1");
$hasRolesTable = (bool)$stmt->fetchColumn();
} catch (Throwable) {
$hasRolesTable = false;
}
$sql = '
SELECT u.id,
u.email,
u.status,
u.created_at,
COALESCE(p.display_name, "") AS display_name
FROM users u
LEFT JOIN user_profiles p ON p.user_id = u.id
ORDER BY u.id ASC
';
$stmt = $pdo->query($sql);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if ($hasRolesTable && $users) {
$roleStmt = $pdo->query('SELECT user_id, role FROM user_roles ORDER BY user_id ASC, role ASC');
$rolesByUser = [];
foreach ($roleStmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
$rolesByUser[(int)$row['user_id']][] = (string)$row['role'];
}
foreach ($users as &$user) {
$user['roles'] = $rolesByUser[(int)$user['id']] ?? [];
}
unset($user);
}
} catch (Throwable $e) {
$error = $e->getMessage();
}
?>
<main class="section">
<div class="container">
<div class="section__intro">
<h1>Staging: Registrierte Benutzer</h1>
</div>
<div class="card dash-card">
<?php if ($error !== ''): ?>
<div class="toast-bar" style="border-color:#f87171; color:#991b1b; margin-bottom:12px;">
Fehler: <?= htmlspecialchars($error, ENT_QUOTES) ?>
</div>
<?php endif; ?>
<p class="muted small">Diese Seite ist nur im Staging verfügbar.</p>
<?php if (!$users): ?>
<p class="muted">Keine Benutzer gefunden.</p>
<?php else: ?>
<div style="overflow:auto; margin-top:14px;">
<table style="width:100%; border-collapse:collapse;">
<thead>
<tr style="text-align:left; border-bottom:1px solid var(--color-border);">
<th style="padding:10px 8px;">ID</th>
<th style="padding:10px 8px;">Anzeigename</th>
<th style="padding:10px 8px;">E-Mail</th>
<th style="padding:10px 8px;">Status</th>
<th style="padding:10px 8px;">Registriert</th>
<th style="padding:10px 8px;">Rollen</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<tr style="border-bottom:1px solid var(--color-border);">
<td style="padding:10px 8px; white-space:nowrap;"><?= (int)$user['id'] ?></td>
<td style="padding:10px 8px;"><?= htmlspecialchars((string)$user['display_name'], ENT_QUOTES) ?></td>
<td style="padding:10px 8px;"><?= htmlspecialchars((string)$user['email'], ENT_QUOTES) ?></td>
<td style="padding:10px 8px;"><?= htmlspecialchars((string)$user['status'], ENT_QUOTES) ?></td>
<td style="padding:10px 8px; white-space:nowrap;"><?= htmlspecialchars((string)$user['created_at'], ENT_QUOTES) ?></td>
<td style="padding:10px 8px;"><?= htmlspecialchars(implode(', ', $user['roles'] ?? []), ENT_QUOTES) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</main>

52
public/page/ueber-uns.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
?>
<main>
<section class="section">
<div class="container">
<div class="section__intro section__intro--wide">
<h1>Über uns</h1>
<p>
Ich bin Lars, Vater zweier Töchter, und Papa-Kind-Treff ist aus einem sehr persönlichen Wunsch heraus entstanden.
Ich habe in den letzten Jahren immer wieder gemerkt, dass die Vaterrolle in unserer Gesellschaft zwar sichtbarer
geworden ist, aber an vielen Stellen noch immer nicht ganz selbstverständlich, offen oder positiv gesehen wird.
Vieles wirkt nach außen modern, und trotzdem bleiben Väter im Alltag oft eher für sich, zurückhaltend und vorsichtig.
</p>
<p>
Genau das habe ich auch auf Spielplätzen, bei kleinen Treffen oder unterwegs mit meinen Kindern erlebt. Man sieht
sich, man nickt sich vielleicht freundlich zu, aber echte Gespräche entstehen oft nur selten. Ich kenne diese
Zurückhaltung nicht nur von anderen Vätern, sondern auch von mir selbst. Gerade deshalb weiß ich, wie schnell man
nebeneinander steht, ohne wirklich in Kontakt zu kommen, obwohl man eigentlich ähnliche Fragen, ähnliche Themen
und vielleicht sogar denselben Wunsch nach Austausch hat.
</p>
<p>
Die Idee zu dieser Seite ist deshalb nicht nur entstanden, um Termine und Treffen sichtbar zu machen, sondern auch,
um Freundschaften zu Gleichgesinnten möglich zu machen. Ich wollte einen Ort schaffen, an dem Väter unkompliziert
zueinanderfinden können, ohne große Hürden, ohne unangenehmes Fremdeln und ohne das Gefühl, mit den eigenen Themen
allein zu sein. Es geht mir um ehrlichen Austausch, um aktuelle Themen aus dem Familienalltag, um Fragen, Gedanken,
Erfahrungen und auch um das gute Gefühl, dass andere manches ganz ähnlich erleben.
</p>
<p>
Vor allem am Anfang, als ich selbst noch ein Neuvater war, gab es viele Situationen, in denen ich Fragen hatte und
mir Unterstützung gewünscht habe. Nicht immer braucht man gleich große Antworten. Manchmal hilft schon ein kurzer
Austausch, eine andere Perspektive oder einfach das Wissen, dass man mit einer Unsicherheit nicht allein ist. Genau
dafür soll diese Plattform Raum geben: für kleine Fragen, große Gedanken und Gespräche, die im Alltag oft zu kurz kommen.
</p>
<p>
Gleichzeitig geht es auch um das schöne, praktische Leben mit Kindern. Ich wollte für mich einen Weg finden, nicht
immer wieder dasselbe mit den Kindern zu machen, sondern neue Ideen, neue Orte und neue Begegnungen zu entdecken.
Dazu gehört für mich auch der Wunsch, vielleicht sogar im Urlaub unkompliziert gute Aktivitäten zu finden oder
Spielpartner für die Kinder kennenzulernen, ohne dafür stundenlang das Internet durchsuchen zu müssen.
Gemeinsame Zeit darf vertraut sein, aber sie darf auch inspirierend, lebendig und überraschend bleiben. Wenn daraus
nicht nur schöne Momente mit den Kindern entstehen, sondern auch echte Kontakte unter Vätern, dann erfüllt Papa-Kind-Treff
genau den Gedanken, mit dem ich diese Seite begonnen habe.
</p>
</div>
</div>
</section>
</main>

0
public/page/verify.php Normal file → Executable file
View File

95
schema.sql Normal file → Executable file
View File

@@ -29,6 +29,7 @@ CREATE TABLE user_profiles (
region VARCHAR(120) NULL, region VARCHAR(120) NULL,
lat DECIMAL(10,7) NULL, lat DECIMAL(10,7) NULL,
lng DECIMAL(10,7) NULL, lng DECIMAL(10,7) NULL,
location_tracking_preference ENUM('disabled','prompt','enabled') NOT NULL DEFAULT 'prompt',
contact_phone VARBINARY(512) NULL, contact_phone VARBINARY(512) NULL,
contact_email VARBINARY(512) NULL, contact_email VARBINARY(512) NULL,
profession VARBINARY(512) NULL, profession VARBINARY(512) NULL,
@@ -99,15 +100,37 @@ CREATE TABLE event_participants (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Community / Forum -- Community / Forum
CREATE TABLE forum_categories (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(180) NOT NULL,
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE forum_boards (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
category_id BIGINT UNSIGNED NOT NULL,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(180) NOT NULL,
description TEXT NULL,
icon VARCHAR(32) NULL,
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
CONSTRAINT fk_fb_category FOREIGN KEY (category_id) REFERENCES forum_categories(id) ON DELETE CASCADE,
INDEX idx_fb_category (category_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE forum_threads ( CREATE TABLE forum_threads (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
board_id BIGINT UNSIGNED NULL,
user_id BIGINT UNSIGNED NOT NULL, user_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(200) NOT NULL, title VARCHAR(200) NOT NULL,
body TEXT NOT NULL, body TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_ft_board FOREIGN KEY (board_id) REFERENCES forum_boards(id) ON DELETE SET NULL,
CONSTRAINT fk_ft_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_ft_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_ft_created (created_at) INDEX idx_ft_created (created_at),
INDEX idx_ft_board (board_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE forum_posts ( CREATE TABLE forum_posts (
@@ -115,13 +138,83 @@ CREATE TABLE forum_posts (
thread_id BIGINT UNSIGNED NOT NULL, thread_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL, user_id BIGINT UNSIGNED NOT NULL,
body TEXT NOT NULL, body TEXT NOT NULL,
highlighted_by BIGINT UNSIGNED NULL,
highlighted_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_fp_thread FOREIGN KEY (thread_id) REFERENCES forum_threads(id) ON DELETE CASCADE, CONSTRAINT fk_fp_thread FOREIGN KEY (thread_id) REFERENCES forum_threads(id) ON DELETE CASCADE,
CONSTRAINT fk_fp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_fp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_fp_highlighted_by FOREIGN KEY (highlighted_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_fp_thread (thread_id) INDEX idx_fp_thread (thread_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE forum_post_feedback (
post_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
value TINYINT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (post_id, user_id),
CONSTRAINT fk_fpf_post FOREIGN KEY (post_id) REFERENCES forum_posts(id) ON DELETE CASCADE,
CONSTRAINT fk_fpf_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_fpf_value (value)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE forum_reports (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
reporter_user_id BIGINT UNSIGNED NOT NULL,
target_type ENUM('thread','post') NOT NULL,
target_id BIGINT UNSIGNED NOT NULL,
reason TEXT NOT NULL,
status ENUM('open','resolved','dismissed') NOT NULL DEFAULT 'open',
moderator_user_id BIGINT UNSIGNED NULL,
moderator_note TEXT NULL,
resolved_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_fr_reporter FOREIGN KEY (reporter_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_fr_moderator FOREIGN KEY (moderator_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_fr_status (status),
INDEX idx_fr_target (target_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE user_roles (
user_id BIGINT UNSIGNED NOT NULL,
role ENUM('forum_admin','site_admin','owner') NOT NULL,
assigned_by BIGINT UNSIGNED NULL,
assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role),
CONSTRAINT fk_ur_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_ur_assigned_by FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE community_admin_applications (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
motivation TEXT NOT NULL,
status ENUM('open','approved','rejected') NOT NULL DEFAULT 'open',
decision_reason TEXT NULL,
decided_by BIGINT UNSIGNED NULL,
decided_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_caa_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_caa_decided_by FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_caa_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE community_user_restrictions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
restriction_type ENUM('thread_create_blocked','reply_blocked') NOT NULL,
reason TEXT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_by BIGINT UNSIGNED NOT NULL,
expires_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_cur_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_cur_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_cur_user_active (user_id, active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Punkte-Tracking (persistent, config-Änderungen wirken nur auf neue Einträge) -- Punkte-Tracking (persistent, config-Änderungen wirken nur auf neue Einträge)
CREATE TABLE user_points ( CREATE TABLE user_points (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

0
src/.gitkeep Normal file → Executable file
View File

67
src/App/AccountPages.php Normal file → Executable file
View File

@@ -153,6 +153,12 @@ final class AccountPages
$info = ''; $info = '';
$crypto = null; $crypto = null;
try { $crypto = new Crypto($app->config()); } catch (\Throwable) {} try { $crypto = new Crypto($app->config()); } catch (\Throwable) {}
$communityCfg = dirname(__DIR__, 2) . '/config/community.php';
$communityConfig = file_exists($communityCfg) ? require $communityCfg : [];
$community = $pdo ? new Community($pdo, $communityConfig) : null;
$communityAccess = $pdo ? new CommunityAccess($pdo, $communityConfig) : null;
$communityMigration = $pdo ? new CommunityMigration($pdo) : null;
$profileSettings = $pdo ? new ProfileSettings($pdo) : null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
@@ -163,7 +169,11 @@ final class AccountPages
$languages = implode(', ', array_map('trim', $languages)); $languages = implode(', ', array_map('trim', $languages));
} }
$phoneEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['contact_phone'])) : trim((string)$_POST['contact_phone']); $phoneEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['contact_phone'])) : trim((string)$_POST['contact_phone']);
$stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, updated_at=NOW() WHERE user_id=:id'); $locationPreference = (string)($_POST['location_tracking_preference'] ?? 'prompt');
if ($profileSettings) {
$profileSettings->ensureSchema();
}
$stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, location_tracking_preference=:locationPref, updated_at=NOW() WHERE user_id=:id');
$stmt?->execute([ $stmt?->execute([
'name' => trim((string)$_POST['display_name']), 'name' => trim((string)$_POST['display_name']),
'fname' => trim((string)$_POST['first_name']), 'fname' => trim((string)$_POST['first_name']),
@@ -174,6 +184,7 @@ final class AccountPages
'langs' => trim((string)$languages), 'langs' => trim((string)$languages),
'about' => trim((string)$_POST['about']), 'about' => trim((string)$_POST['about']),
'phone' => $phoneEnc, 'phone' => $phoneEnc,
'locationPref' => in_array($locationPreference, ['disabled', 'prompt', 'enabled'], true) ? $locationPreference : 'prompt',
'id' => $userId, 'id' => $userId,
]); ]);
$info = 'Profil gespeichert.'; $info = 'Profil gespeichert.';
@@ -282,6 +293,22 @@ final class AccountPages
'id' => $eventId, 'id' => $eventId,
]); ]);
$info = 'Event wurde abgesagt.'; $info = 'Event wurde abgesagt.';
} elseif ($action === 'community_admin_apply') {
if (!$community || !$communityAccess) {
throw new \RuntimeException('Community-Funktionen sind aktuell nicht verfügbar.');
}
$points = $community->computePoints($userId);
if (!$communityAccess->canApplyForForumAdmin($userId, $points)) {
throw new \RuntimeException('Du kannst dich aktuell nicht als Forum-Admin bewerben.');
}
$communityAccess->submitApplication($userId, (string)($_POST['motivation'] ?? ''));
$info = 'Deine Bewerbung wurde eingereicht.';
} elseif ($action === 'community_migrate') {
if (!$communityAccess || !$communityMigration || !$communityAccess->canManageApplications($userId)) {
throw new \RuntimeException('Keine Berechtigung für die Community-Migration.');
}
$communityMigration->apply();
$info = 'Die Community-Migration wurde ausgeführt.';
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$error = $e->getMessage(); $error = $e->getMessage();
@@ -300,8 +327,12 @@ final class AccountPages
'about' => '', 'about' => '',
'email' => '', 'email' => '',
'contact_phone' => '', 'contact_phone' => '',
'location_tracking_preference' => 'prompt',
]; ];
$stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1'); if ($profileSettings) {
$profileSettings->ensureSchema();
}
$stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone, p.location_tracking_preference FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1');
$stmt?->execute(['id' => $userId]); $stmt?->execute(['id' => $userId]);
$row = $stmt?->fetch(\PDO::FETCH_ASSOC); $row = $stmt?->fetch(\PDO::FETCH_ASSOC);
if ($row) { if ($row) {
@@ -353,7 +384,37 @@ final class AccountPages
$editEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null; $editEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null;
} }
return compact('flash','info','error','profile','children','eventsUpcoming','eventsPast','editEvent'); $communityPoints = $community ? $community->computePoints($userId) : 0.0;
$communityLevel = $community ? $community->membershipLevel($communityPoints) : ['label' => '', 'icon' => ''];
$communityRoles = $communityAccess ? $communityAccess->getUserRoles($userId) : [];
$communityApplication = $communityAccess ? $communityAccess->getLatestApplication($userId) : null;
$communityCanApply = $communityAccess ? $communityAccess->canApplyForForumAdmin($userId, $communityPoints) : false;
$communityRestrictions = $communityAccess ? $communityAccess->getRestrictionState($userId) : [
'thread_create_blocked' => false,
'reply_blocked' => false,
'reason' => null,
];
$communityIsSiteAdmin = $communityAccess ? $communityAccess->canManageApplications($userId) : false;
$communityMigrationStatus = ($communityMigration && $communityIsSiteAdmin) ? $communityMigration->status() : null;
return compact(
'flash',
'info',
'error',
'profile',
'children',
'eventsUpcoming',
'eventsPast',
'editEvent',
'communityPoints',
'communityLevel',
'communityRoles',
'communityApplication',
'communityCanApply',
'communityRestrictions',
'communityIsSiteAdmin',
'communityMigrationStatus'
);
} }
private static function geocodeAddress(?string $street, ?string $zip, ?string $city, ?string $region): array private static function geocodeAddress(?string $street, ?string $zip, ?string $city, ?string $region): array

0
src/App/App.php Normal file → Executable file
View File

0
src/App/Assets.php Normal file → Executable file
View File

0
src/App/Auth.php Normal file → Executable file
View File

354
src/App/Community.php Normal file → Executable file
View File

@@ -5,17 +5,64 @@ namespace App;
final class Community final class Community
{ {
private ?array $forumStructure = null;
private array $tableCache = [];
private array $columnCache = [];
public function __construct(private \PDO $pdo, private array $config) public function __construct(private \PDO $pdo, private array $config)
{ {
} }
public function getStats(): array
{
$threads = (int)$this->pdo->query('SELECT COUNT(*) FROM forum_threads')->fetchColumn();
$posts = (int)$this->pdo->query('SELECT COUNT(*) FROM forum_posts')->fetchColumn();
$members = (int)$this->pdo->query('SELECT COUNT(*) FROM users WHERE status = "active"')->fetchColumn();
return [
'threads' => $threads,
'posts' => $posts,
'members' => $members,
];
}
public function createThread(int $userId, string $title, string $body): void public function createThread(int $userId, string $title, string $body): void
{ {
$this->createThreadInBoard($userId, null, $title, $body);
}
public function createThreadInBoard(int $userId, ?string $boardSlug, string $title, string $body): void
{
$title = trim($title);
$body = trim($body);
if ($title === '' || $body === '') {
throw new \RuntimeException('Titel und Text sind erforderlich.');
}
if ($this->hasColumn('forum_threads', 'board_id') && $this->hasTable('forum_boards')) {
$boardId = null;
if ($boardSlug !== null && $boardSlug !== '') {
$board = $this->getBoardBySlug($boardSlug);
if (!$board) {
throw new \RuntimeException('Forum-Bereich nicht gefunden.');
}
$boardId = (int)$board['id'];
}
$stmt = $this->pdo->prepare('INSERT INTO forum_threads (board_id, user_id, title, body) VALUES (:boardId, :uid, :title, :body)');
$stmt->bindValue(':boardId', $boardId, $boardId === null ? \PDO::PARAM_NULL : \PDO::PARAM_INT);
$stmt->bindValue(':uid', $userId, \PDO::PARAM_INT);
$stmt->bindValue(':title', $title, \PDO::PARAM_STR);
$stmt->bindValue(':body', $body, \PDO::PARAM_STR);
$stmt->execute();
return;
}
$stmt = $this->pdo->prepare('INSERT INTO forum_threads (user_id, title, body) VALUES (:uid, :title, :body)'); $stmt = $this->pdo->prepare('INSERT INTO forum_threads (user_id, title, body) VALUES (:uid, :title, :body)');
$stmt->execute([ $stmt->execute([
':uid' => $userId, ':uid' => $userId,
':title' => trim($title), ':title' => $title,
':body' => trim($body), ':body' => $body,
]); ]);
} }
@@ -29,7 +76,57 @@ final class Community
]); ]);
} }
public function searchThreads(string $query, int $limit = 50): array public function updateThread(int $threadId, string $title, string $body): void
{
$stmt = $this->pdo->prepare('UPDATE forum_threads SET title = :title, body = :body, updated_at = NOW() WHERE id = :id');
$stmt->execute([
'id' => $threadId,
'title' => trim($title),
'body' => trim($body),
]);
}
public function deleteThread(int $threadId): void
{
$stmt = $this->pdo->prepare('DELETE FROM forum_threads WHERE id = :id');
$stmt->execute(['id' => $threadId]);
}
public function updatePost(int $postId, string $body): void
{
$stmt = $this->pdo->prepare('UPDATE forum_posts SET body = :body, updated_at = NOW() WHERE id = :id');
$stmt->execute([
'id' => $postId,
'body' => trim($body),
]);
}
public function deletePost(int $postId): void
{
$stmt = $this->pdo->prepare('DELETE FROM forum_posts WHERE id = :id');
$stmt->execute(['id' => $postId]);
}
public function toggleHelpfulHighlight(int $postId, ?int $userId): void
{
if (!$this->hasColumn('forum_posts', 'highlighted_at')) {
return;
}
if ($userId === null) {
$stmt = $this->pdo->prepare('UPDATE forum_posts SET highlighted_by = NULL, highlighted_at = NULL WHERE id = :id');
$stmt->execute(['id' => $postId]);
return;
}
$stmt = $this->pdo->prepare('UPDATE forum_posts SET highlighted_by = :uid, highlighted_at = NOW() WHERE id = :id');
$stmt->execute([
'uid' => $userId,
'id' => $postId,
]);
}
public function searchThreads(string $query, int $limit = 50, ?string $boardSlug = null): array
{ {
$conditions = []; $conditions = [];
$params = []; $params = [];
@@ -43,17 +140,44 @@ final class Community
$params[$ph2] = '%' . $tok . '%'; $params[$ph2] = '%' . $tok . '%';
$i++; $i++;
} }
$defaultBoard = $this->getBoardBySlug('wochenendideen');
$boardJoin = '';
$boardSelect = "NULL AS board_slug, NULL AS board_title, NULL AS category_slug, NULL AS category_title";
if ($this->hasColumn('forum_threads', 'board_id') && $this->hasTable('forum_boards') && $this->hasTable('forum_categories')) {
$boardJoin = ' LEFT JOIN forum_boards fb ON fb.id = ft.board_id LEFT JOIN forum_categories fc ON fc.id = fb.category_id ';
$boardSelect = 'fb.slug AS board_slug, fb.title AS board_title, fc.slug AS category_slug, fc.title AS category_title';
}
$where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : ''; $where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : '';
$sql = "SELECT ft.id, ft.title, ft.body, ft.created_at, $sql = "SELECT ft.id, ft.title, ft.body, ft.created_at, ft.updated_at,
u.id as uid, u.created_at as user_created, u.id as uid, u.created_at as user_created,
p.display_name, p.display_name,
$boardSelect,
(SELECT COUNT(*) FROM forum_posts fp WHERE fp.thread_id = ft.id) AS answers, (SELECT COUNT(*) FROM forum_posts fp WHERE fp.thread_id = ft.id) AS answers,
COALESCE(
(SELECT MAX(fp.created_at) FROM forum_posts fp WHERE fp.thread_id = ft.id),
ft.created_at
) AS last_activity_at,
COALESCE(
(
SELECT p2.display_name
FROM forum_posts fp3
JOIN users u2 ON u2.id = fp3.user_id
LEFT JOIN user_profiles p2 ON p2.user_id = u2.id
WHERE fp3.thread_id = ft.id
ORDER BY fp3.created_at DESC
LIMIT 1
),
p.display_name,
'Mitglied'
) AS last_activity_by,
(SELECT COUNT(*) FROM forum_posts fp2 WHERE fp2.user_id = u.id) + (SELECT COUNT(*) FROM forum_posts fp2 WHERE fp2.user_id = u.id) +
(SELECT COUNT(*) FROM forum_threads ft2 WHERE ft2.user_id = u.id) AS user_posts (SELECT COUNT(*) FROM forum_threads ft2 WHERE ft2.user_id = u.id) AS user_posts
FROM forum_threads ft FROM forum_threads ft
JOIN users u ON u.id = ft.user_id JOIN users u ON u.id = ft.user_id
LEFT JOIN user_profiles p ON p.user_id = u.id LEFT JOIN user_profiles p ON p.user_id = u.id
$boardJoin
WHERE 1=1 $where WHERE 1=1 $where
ORDER BY ft.created_at DESC ORDER BY ft.created_at DESC
LIMIT :lim"; LIMIT :lim";
@@ -63,29 +187,129 @@ final class Community
} }
$stmt->bindValue(':lim', $limit, \PDO::PARAM_INT); $stmt->bindValue(':lim', $limit, \PDO::PARAM_INT);
$stmt->execute(); $stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
$rows = array_map(fn(array $row) => $this->applyFallbackBoard($row, $defaultBoard), $rows);
if ($boardSlug !== null && $boardSlug !== '') {
$rows = array_values(array_filter($rows, fn(array $row): bool => (string)($row['board_slug'] ?? '') === $boardSlug));
}
return $rows;
} }
public function listThreads(int $limit = 50): array public function listThreads(int $limit = 50, ?string $boardSlug = null): array
{ {
return $this->searchThreads('', $limit); return $this->searchThreads('', $limit, $boardSlug);
} }
public function getThread(int $id): ?array public function getThread(int $id): ?array
{ {
$stmt = $this->pdo->prepare('SELECT ft.*, p.display_name FROM forum_threads ft LEFT JOIN user_profiles p ON p.user_id = ft.user_id WHERE ft.id = :id'); $select = 'ft.*, p.display_name';
$join = '';
if ($this->hasColumn('forum_threads', 'board_id') && $this->hasTable('forum_boards') && $this->hasTable('forum_categories')) {
$select .= ', fb.slug AS board_slug, fb.title AS board_title, fc.slug AS category_slug, fc.title AS category_title';
$join = ' LEFT JOIN forum_boards fb ON fb.id = ft.board_id LEFT JOIN forum_categories fc ON fc.id = fb.category_id ';
}
$stmt = $this->pdo->prepare("SELECT $select FROM forum_threads ft LEFT JOIN user_profiles p ON p.user_id = ft.user_id $join WHERE ft.id = :id");
$stmt->execute([':id' => $id]); $stmt->execute([':id' => $id]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC); $row = $stmt->fetch(\PDO::FETCH_ASSOC);
return $row ?: null; if (!$row) {
return null;
}
return $this->applyFallbackBoard($row, $this->getBoardBySlug('wochenendideen'));
} }
public function listPosts(int $threadId): array public function listPosts(int $threadId): array
{ {
$stmt = $this->pdo->prepare('SELECT fp.*, p.display_name FROM forum_posts fp LEFT JOIN user_profiles p ON p.user_id = fp.user_id WHERE fp.thread_id = :id ORDER BY fp.created_at ASC'); $select = 'fp.*, p.display_name';
if ($this->hasColumn('forum_posts', 'highlighted_at')) {
$select .= ', hp.display_name AS highlighted_by_name';
} else {
$select .= ', NULL AS highlighted_by_name';
}
$join = ' LEFT JOIN user_profiles p ON p.user_id = fp.user_id ';
if ($this->hasColumn('forum_posts', 'highlighted_at')) {
$join .= ' LEFT JOIN user_profiles hp ON hp.user_id = fp.highlighted_by ';
}
$stmt = $this->pdo->prepare("SELECT $select FROM forum_posts fp $join WHERE fp.thread_id = :id ORDER BY fp.created_at ASC");
$stmt->execute([':id' => $threadId]); $stmt->execute([':id' => $threadId]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
} }
public function getPost(int $postId): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM forum_posts WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $postId]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
return $row ?: null;
}
public function listForumCategories(): array
{
$structure = $this->forumStructure();
$boards = [];
foreach ($structure as $category) {
foreach ($category['boards'] as $board) {
$boards[$board['slug']] = $board;
}
}
$threadsByBoard = [];
foreach ($this->listThreads(200) as $thread) {
$slug = (string)($thread['board_slug'] ?? '');
if ($slug === '' || !isset($boards[$slug])) {
continue;
}
$threadsByBoard[$slug][] = $thread;
}
foreach ($structure as &$category) {
foreach ($category['boards'] as &$board) {
$items = $threadsByBoard[$board['slug']] ?? [];
$board['thread_count'] = count($items);
$replyCount = 0;
foreach ($items as $item) {
$replyCount += (int)($item['answers'] ?? 0);
}
$board['post_count'] = $replyCount;
$board['latest_thread'] = $items[0] ?? null;
}
unset($board);
}
unset($category);
return $structure;
}
public function getBoardBySlug(string $slug): ?array
{
foreach ($this->forumStructure() as $category) {
foreach ($category['boards'] as $board) {
if ($board['slug'] === $slug) {
if ($this->hasTable('forum_boards')) {
$stmt = $this->pdo->prepare('
SELECT fb.id, fb.slug, fb.title, fb.description, fb.icon, fc.slug AS category_slug, fc.title AS category_title
FROM forum_boards fb
JOIN forum_categories fc ON fc.id = fb.category_id
WHERE fb.slug = :slug
LIMIT 1
');
$stmt->execute(['slug' => $slug]);
$dbBoard = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($dbBoard) {
return array_merge($board, $dbBoard);
}
}
return array_merge($board, [
'category_slug' => $category['slug'],
'category_title' => $category['title'],
]);
}
}
}
return null;
}
public function computePoints(int $userId): float public function computePoints(int $userId): float
{ {
// Primär: aggregierte Werte aus user_points_totals, Fallback: Summe aus user_points // Primär: aggregierte Werte aus user_points_totals, Fallback: Summe aus user_points
@@ -193,4 +417,114 @@ final class Community
$fallback = $levels ? $levels[count($levels)-1] : ['label' => 'New Daddy','icon' => '']; $fallback = $levels ? $levels[count($levels)-1] : ['label' => 'New Daddy','icon' => ''];
return ['label' => $fallback['label'], 'icon' => $fallback['icon'] ?? '']; return ['label' => $fallback['label'], 'icon' => $fallback['icon'] ?? ''];
} }
private function forumStructure(): array
{
if ($this->forumStructure !== null) {
return $this->forumStructure;
}
$file = dirname(__DIR__, 2) . '/config/community_forums.php';
$this->forumStructure = file_exists($file) ? (array) require $file : [];
$this->syncForumStructure();
return $this->forumStructure;
}
private function syncForumStructure(): void
{
if (!$this->hasTable('forum_categories') || !$this->hasTable('forum_boards')) {
return;
}
foreach ($this->forumStructure as $categoryIndex => $category) {
$stmt = $this->pdo->prepare('INSERT INTO forum_categories (slug, title, sort_order) VALUES (:slug, :title, :sortOrder) ON DUPLICATE KEY UPDATE title = VALUES(title), sort_order = VALUES(sort_order)');
$stmt->execute([
'slug' => $category['slug'],
'title' => $category['title'],
'sortOrder' => $categoryIndex,
]);
$catIdStmt = $this->pdo->prepare('SELECT id FROM forum_categories WHERE slug = :slug LIMIT 1');
$catIdStmt->execute(['slug' => $category['slug']]);
$categoryId = (int)$catIdStmt->fetchColumn();
foreach ($category['boards'] as $boardIndex => $board) {
$boardStmt = $this->pdo->prepare('
INSERT INTO forum_boards (category_id, slug, title, description, icon, sort_order)
VALUES (:categoryId, :slug, :title, :description, :icon, :sortOrder)
ON DUPLICATE KEY UPDATE
category_id = VALUES(category_id),
title = VALUES(title),
description = VALUES(description),
icon = VALUES(icon),
sort_order = VALUES(sort_order)
');
$boardStmt->execute([
'categoryId' => $categoryId,
'slug' => $board['slug'],
'title' => $board['title'],
'description' => $board['description'],
'icon' => $board['icon'] ?? null,
'sortOrder' => $boardIndex,
]);
}
}
// Migrate legacy uncategorized threads into a default board once board support is available.
if ($this->hasColumn('forum_threads', 'board_id')) {
$defaultBoard = $this->getBoardBySlug('wochenendideen');
if ($defaultBoard && !empty($defaultBoard['id'])) {
$stmt = $this->pdo->prepare('UPDATE forum_threads SET board_id = :boardId WHERE board_id IS NULL');
$stmt->execute(['boardId' => (int)$defaultBoard['id']]);
}
}
}
private function applyFallbackBoard(array $row, ?array $defaultBoard): array
{
if (!empty($row['board_slug']) || !$defaultBoard) {
return $row;
}
$row['board_slug'] = $defaultBoard['slug'] ?? null;
$row['board_title'] = $defaultBoard['title'] ?? null;
$row['category_slug'] = $defaultBoard['category_slug'] ?? null;
$row['category_title'] = $defaultBoard['category_title'] ?? null;
return $row;
}
private function hasTable(string $table): bool
{
if (array_key_exists($table, $this->tableCache)) {
return $this->tableCache[$table];
}
try {
$stmt = $this->pdo->prepare('SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = :table LIMIT 1');
$stmt->execute(['table' => $table]);
return $this->tableCache[$table] = (bool)$stmt->fetchColumn();
} catch (\Throwable) {
return $this->tableCache[$table] = false;
}
}
private function hasColumn(string $table, string $column): bool
{
$key = $table . '.' . $column;
if (array_key_exists($key, $this->columnCache)) {
return $this->columnCache[$key];
}
try {
$stmt = $this->pdo->prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = :table AND column_name = :column LIMIT 1');
$stmt->execute([
'table' => $table,
'column' => $column,
]);
return $this->columnCache[$key] = (bool)$stmt->fetchColumn();
} catch (\Throwable) {
return $this->columnCache[$key] = false;
}
}
} }

484
src/App/CommunityAccess.php Normal file
View File

@@ -0,0 +1,484 @@
<?php
declare(strict_types=1);
namespace App;
final class CommunityAccess
{
private array $tableCache = [];
private array $columnCache = [];
public function __construct(private \PDO $pdo, private array $communityConfig)
{
}
public function getUserRoles(int $userId): array
{
if ($userId <= 0) {
return [];
}
$roles = [];
if ($this->hasTable('user_roles')) {
$stmt = $this->pdo->prepare('SELECT role FROM user_roles WHERE user_id = :uid');
$stmt->execute(['uid' => $userId]);
$roles = array_map('strval', $stmt->fetchAll(\PDO::FETCH_COLUMN) ?: []);
}
if ($userId === 1 && !in_array('owner', $roles, true)) {
$roles[] = 'owner';
}
if (in_array('owner', $roles, true)) {
foreach (['site_admin', 'forum_admin'] as $derived) {
if (!in_array($derived, $roles, true)) {
$roles[] = $derived;
}
}
}
if (in_array('site_admin', $roles, true) && !in_array('forum_admin', $roles, true)) {
$roles[] = 'forum_admin';
}
return array_values(array_unique($roles));
}
public function hasRole(int $userId, string $role): bool
{
return in_array($role, $this->getUserRoles($userId), true);
}
public function canModerateForum(int $userId): bool
{
return $this->hasRole($userId, 'forum_admin');
}
public function canManageApplications(int $userId): bool
{
return $this->hasRole($userId, 'site_admin');
}
public function canManageRoles(int $userId): bool
{
return $this->hasRole($userId, 'owner');
}
public function getRestrictionState(int $userId): array
{
$state = [
'thread_create_blocked' => false,
'reply_blocked' => false,
'reason' => null,
];
if ($userId <= 0 || !$this->hasTable('community_user_restrictions')) {
return $state;
}
$stmt = $this->pdo->prepare('
SELECT restriction_type, reason
FROM community_user_restrictions
WHERE user_id = :uid
AND active = 1
AND (expires_at IS NULL OR expires_at > NOW())
');
$stmt->execute(['uid' => $userId]);
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
if (isset($state[$row['restriction_type']])) {
$state[$row['restriction_type']] = true;
if ($state['reason'] === null && !empty($row['reason'])) {
$state['reason'] = (string)$row['reason'];
}
}
}
return $state;
}
public function canCreateThread(int $userId): bool
{
return !$this->getRestrictionState($userId)['thread_create_blocked'];
}
public function canReply(int $userId): bool
{
return !$this->getRestrictionState($userId)['reply_blocked'];
}
public function canApplyForForumAdmin(int $userId, float $points): bool
{
if ($userId <= 0 || $points < 750.0 || !$this->hasTable('community_admin_applications')) {
return false;
}
foreach (['forum_admin', 'site_admin', 'owner'] as $role) {
if ($this->hasRole($userId, $role)) {
return false;
}
}
$latest = $this->getLatestApplication($userId);
return !$latest || $latest['status'] !== 'open';
}
public function getLatestApplication(int $userId): ?array
{
if ($userId <= 0 || !$this->hasTable('community_admin_applications')) {
return null;
}
$stmt = $this->pdo->prepare('
SELECT caa.*, p.display_name AS decided_by_name
FROM community_admin_applications caa
LEFT JOIN user_profiles p ON p.user_id = caa.decided_by
WHERE caa.user_id = :uid
ORDER BY caa.created_at DESC
LIMIT 1
');
$stmt->execute(['uid' => $userId]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
return $row ?: null;
}
public function submitApplication(int $userId, string $motivation): void
{
if (!$this->hasTable('community_admin_applications')) {
throw new \RuntimeException('Admin-Bewerbungen stehen erst nach dem Datenbank-Update zur Verfügung.');
}
$motivation = trim($motivation);
if ($motivation === '') {
throw new \RuntimeException('Bitte begründe deine Bewerbung.');
}
$stmt = $this->pdo->prepare('INSERT INTO community_admin_applications (user_id, motivation, status) VALUES (:uid, :motivation, :status)');
$stmt->execute([
'uid' => $userId,
'motivation' => $motivation,
'status' => 'open',
]);
}
public function listApplications(string $status = 'open'): array
{
if (!$this->hasTable('community_admin_applications')) {
return [];
}
$sql = '
SELECT caa.*, up.display_name, u.email, dp.display_name AS decided_by_name
FROM community_admin_applications caa
JOIN users u ON u.id = caa.user_id
LEFT JOIN user_profiles up ON up.user_id = caa.user_id
LEFT JOIN user_profiles dp ON dp.user_id = caa.decided_by
';
$params = [];
if ($status !== '') {
$sql .= ' WHERE caa.status = :status';
$params['status'] = $status;
}
$sql .= ' ORDER BY caa.created_at DESC';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
public function decideApplication(int $adminUserId, int $applicationId, string $decision, ?string $reason = null): void
{
if (!$this->canManageApplications($adminUserId)) {
throw new \RuntimeException('Keine Berechtigung.');
}
if (!$this->hasTable('community_admin_applications')) {
throw new \RuntimeException('Admin-Bewerbungen stehen erst nach dem Datenbank-Update zur Verfügung.');
}
$decision = $decision === 'approved' ? 'approved' : 'rejected';
$stmt = $this->pdo->prepare('SELECT * FROM community_admin_applications WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $applicationId]);
$application = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$application) {
throw new \RuntimeException('Bewerbung nicht gefunden.');
}
$update = $this->pdo->prepare('
UPDATE community_admin_applications
SET status = :status, decision_reason = :reason, decided_by = :decidedBy, decided_at = NOW()
WHERE id = :id
');
$update->execute([
'status' => $decision,
'reason' => trim((string)$reason) ?: null,
'decidedBy' => $adminUserId,
'id' => $applicationId,
]);
if ($decision === 'approved') {
$this->assignRole($adminUserId, (int)$application['user_id'], 'forum_admin');
}
}
public function assignRole(int $actingUserId, int $targetUserId, string $role): void
{
if (!$this->canManageRoles($actingUserId) && !$this->canManageApplications($actingUserId)) {
throw new \RuntimeException('Keine Berechtigung.');
}
if (!$this->hasTable('user_roles')) {
throw new \RuntimeException('Rollen stehen erst nach dem Datenbank-Update zur Verfügung.');
}
if (!in_array($role, ['forum_admin', 'site_admin', 'owner'], true)) {
throw new \RuntimeException('Ungültige Rolle.');
}
if ($role !== 'forum_admin' && !$this->canManageRoles($actingUserId)) {
throw new \RuntimeException('Nur der Inhaber darf diese Rolle vergeben.');
}
$stmt = $this->pdo->prepare('INSERT IGNORE INTO user_roles (user_id, role, assigned_by) VALUES (:uid, :role, :by)');
$stmt->execute([
'uid' => $targetUserId,
'role' => $role,
'by' => $actingUserId,
]);
}
public function revokeRole(int $actingUserId, int $targetUserId, string $role): void
{
if (!$this->canManageRoles($actingUserId)) {
throw new \RuntimeException('Keine Berechtigung.');
}
if (!$this->hasTable('user_roles')) {
throw new \RuntimeException('Rollen stehen erst nach dem Datenbank-Update zur Verfügung.');
}
$stmt = $this->pdo->prepare('DELETE FROM user_roles WHERE user_id = :uid AND role = :role');
$stmt->execute([
'uid' => $targetUserId,
'role' => $role,
]);
}
public function listRoleAssignments(): array
{
if (!$this->hasTable('user_roles')) {
return [];
}
$stmt = $this->pdo->query('
SELECT ur.user_id, ur.role, ur.assigned_at, up.display_name, u.email
FROM user_roles ur
JOIN users u ON u.id = ur.user_id
LEFT JOIN user_profiles up ON up.user_id = ur.user_id
ORDER BY FIELD(ur.role, "owner", "site_admin", "forum_admin"), ur.assigned_at ASC
');
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
public function setRestriction(int $actingUserId, int $targetUserId, string $type, string $reason): void
{
if (!$this->canModerateForum($actingUserId)) {
throw new \RuntimeException('Keine Berechtigung.');
}
if (!$this->hasTable('community_user_restrictions')) {
throw new \RuntimeException('Community-Sperren stehen erst nach dem Datenbank-Update zur Verfügung.');
}
if (!in_array($type, ['thread_create_blocked', 'reply_blocked'], true)) {
throw new \RuntimeException('Ungültige Sperre.');
}
$stmt = $this->pdo->prepare('
INSERT INTO community_user_restrictions (user_id, restriction_type, reason, active, created_by)
VALUES (:uid, :type, :reason, 1, :by)
');
$stmt->execute([
'uid' => $targetUserId,
'type' => $type,
'reason' => trim($reason) ?: null,
'by' => $actingUserId,
]);
}
public function clearRestriction(int $actingUserId, int $targetUserId, string $type): void
{
if (!$this->canModerateForum($actingUserId)) {
throw new \RuntimeException('Keine Berechtigung.');
}
if (!$this->hasTable('community_user_restrictions')) {
throw new \RuntimeException('Community-Sperren stehen erst nach dem Datenbank-Update zur Verfügung.');
}
$stmt = $this->pdo->prepare('UPDATE community_user_restrictions SET active = 0 WHERE user_id = :uid AND restriction_type = :type');
$stmt->execute([
'uid' => $targetUserId,
'type' => $type,
]);
}
public function submitReport(int $userId, string $targetType, int $targetId, string $reason): void
{
if (!$this->hasTable('forum_reports')) {
throw new \RuntimeException('Meldungen stehen erst nach dem Datenbank-Update zur Verfügung.');
}
$stmt = $this->pdo->prepare('INSERT INTO forum_reports (reporter_user_id, target_type, target_id, reason, status) VALUES (:uid, :type, :targetId, :reason, :status)');
$stmt->execute([
'uid' => $userId,
'type' => $targetType,
'targetId' => $targetId,
'reason' => trim($reason),
'status' => 'open',
]);
}
public function listOpenReports(): array
{
if (!$this->hasTable('forum_reports')) {
return [];
}
$stmt = $this->pdo->query('
SELECT fr.*, up.display_name AS reporter_name
FROM forum_reports fr
LEFT JOIN user_profiles up ON up.user_id = fr.reporter_user_id
WHERE fr.status = "open"
ORDER BY fr.created_at DESC
');
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
public function resolveReport(int $actingUserId, int $reportId, string $note = ''): void
{
if (!$this->canModerateForum($actingUserId)) {
throw new \RuntimeException('Keine Berechtigung.');
}
if (!$this->hasTable('forum_reports')) {
throw new \RuntimeException('Meldungen stehen erst nach dem Datenbank-Update zur Verfügung.');
}
$stmt = $this->pdo->prepare('
UPDATE forum_reports
SET status = "resolved", moderator_user_id = :uid, moderator_note = :note, resolved_at = NOW()
WHERE id = :id
');
$stmt->execute([
'uid' => $actingUserId,
'note' => trim($note) ?: null,
'id' => $reportId,
]);
}
public function votePost(int $userId, int $postId, int $value): void
{
if (!$this->hasTable('forum_post_feedback')) {
throw new \RuntimeException('Bewertungen stehen erst nach dem Datenbank-Update zur Verfügung.');
}
$value = $value >= 1 ? 1 : -1;
$stmt = $this->pdo->prepare('
INSERT INTO forum_post_feedback (post_id, user_id, value)
VALUES (:postId, :uid, :value)
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = CURRENT_TIMESTAMP
');
$stmt->execute([
'postId' => $postId,
'uid' => $userId,
'value' => $value,
]);
}
public function getPostFeedbackSummary(array $postIds): array
{
if (!$postIds || !$this->hasTable('forum_post_feedback')) {
return [];
}
$placeholders = implode(',', array_fill(0, count($postIds), '?'));
$stmt = $this->pdo->prepare("
SELECT post_id,
SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END) AS helpful_count,
SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END) AS unhelpful_count
FROM forum_post_feedback
WHERE post_id IN ($placeholders)
GROUP BY post_id
");
$stmt->execute(array_values($postIds));
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
$out = [];
foreach ($rows as $row) {
$out[(int)$row['post_id']] = [
'helpful' => (int)$row['helpful_count'],
'unhelpful' => (int)$row['unhelpful_count'],
];
}
return $out;
}
public function getUserPostVotes(int $userId, array $postIds): array
{
if ($userId <= 0 || !$postIds || !$this->hasTable('forum_post_feedback')) {
return [];
}
$placeholders = implode(',', array_fill(0, count($postIds), '?'));
$params = array_merge([$userId], array_values($postIds));
$stmt = $this->pdo->prepare("
SELECT post_id, value
FROM forum_post_feedback
WHERE user_id = ?
AND post_id IN ($placeholders)
");
$stmt->execute($params);
$out = [];
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
$out[(int)$row['post_id']] = (int)$row['value'];
}
return $out;
}
public function canHighlightHelpful(float $points): bool
{
return $points >= 500.0;
}
public function supportsApplications(): bool
{
return $this->hasTable('community_admin_applications');
}
public function supportsReports(): bool
{
return $this->hasTable('forum_reports');
}
public function supportsFeedback(): bool
{
return $this->hasTable('forum_post_feedback');
}
public function supportsRestrictions(): bool
{
return $this->hasTable('community_user_restrictions');
}
public function hasModerationTables(): bool
{
return $this->hasTable('user_roles');
}
private function hasTable(string $table): bool
{
if (array_key_exists($table, $this->tableCache)) {
return $this->tableCache[$table];
}
try {
$stmt = $this->pdo->prepare('SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = :table LIMIT 1');
$stmt->execute(['table' => $table]);
return $this->tableCache[$table] = (bool)$stmt->fetchColumn();
} catch (\Throwable) {
return $this->tableCache[$table] = false;
}
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App;
final class CommunityMigration
{
public function __construct(private \PDO $pdo)
{
}
public function status(): array
{
$checks = [
'forum_categories' => $this->hasTable('forum_categories'),
'forum_boards' => $this->hasTable('forum_boards'),
'forum_post_feedback' => $this->hasTable('forum_post_feedback'),
'forum_reports' => $this->hasTable('forum_reports'),
'user_roles' => $this->hasTable('user_roles'),
'community_admin_applications' => $this->hasTable('community_admin_applications'),
'community_user_restrictions' => $this->hasTable('community_user_restrictions'),
'forum_threads.board_id' => $this->hasColumn('forum_threads', 'board_id'),
'forum_posts.highlighted_by' => $this->hasColumn('forum_posts', 'highlighted_by'),
'forum_posts.highlighted_at' => $this->hasColumn('forum_posts', 'highlighted_at'),
];
$missing = array_keys(array_filter($checks, static fn(bool $ok): bool => !$ok));
return [
'complete' => $missing === [],
'checks' => $checks,
'missing' => $missing,
];
}
public function apply(): array
{
$statements = [
'CREATE TABLE IF NOT EXISTS forum_categories (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(180) NOT NULL,
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS forum_boards (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
category_id BIGINT UNSIGNED NOT NULL,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(180) NOT NULL,
description TEXT NULL,
icon VARCHAR(32) NULL,
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
INDEX idx_fb_category (category_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'ALTER TABLE forum_threads
ADD COLUMN IF NOT EXISTS board_id BIGINT UNSIGNED NULL,
ADD INDEX IF NOT EXISTS idx_ft_board (board_id)',
'ALTER TABLE forum_posts
ADD COLUMN IF NOT EXISTS highlighted_by BIGINT UNSIGNED NULL,
ADD COLUMN IF NOT EXISTS highlighted_at DATETIME NULL',
'CREATE TABLE IF NOT EXISTS forum_post_feedback (
post_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
value TINYINT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (post_id, user_id),
INDEX idx_fpf_value (value)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS forum_reports (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
reporter_user_id BIGINT UNSIGNED NOT NULL,
target_type ENUM("thread","post") NOT NULL,
target_id BIGINT UNSIGNED NOT NULL,
reason TEXT NOT NULL,
status ENUM("open","resolved","dismissed") NOT NULL DEFAULT "open",
moderator_user_id BIGINT UNSIGNED NULL,
moderator_note TEXT NULL,
resolved_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_fr_status (status),
INDEX idx_fr_target (target_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS user_roles (
user_id BIGINT UNSIGNED NOT NULL,
role ENUM("forum_admin","site_admin","owner") NOT NULL,
assigned_by BIGINT UNSIGNED NULL,
assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS community_admin_applications (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
motivation TEXT NOT NULL,
status ENUM("open","approved","rejected") NOT NULL DEFAULT "open",
decision_reason TEXT NULL,
decided_by BIGINT UNSIGNED NULL,
decided_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_caa_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS community_user_restrictions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
restriction_type ENUM("thread_create_blocked","reply_blocked") NOT NULL,
reason TEXT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_by BIGINT UNSIGNED NOT NULL,
expires_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_cur_user_active (user_id, active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
];
foreach ($statements as $sql) {
$this->pdo->exec($sql);
}
// Initial owner/site-admin bootstrap for user 1 if roles table exists.
if ($this->hasTable('user_roles')) {
$stmt = $this->pdo->prepare('INSERT IGNORE INTO user_roles (user_id, role, assigned_by) VALUES (1, "owner", 1)');
$stmt->execute();
$stmt = $this->pdo->prepare('INSERT IGNORE INTO user_roles (user_id, role, assigned_by) VALUES (1, "site_admin", 1)');
$stmt->execute();
}
return $this->status();
}
private function hasTable(string $table): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = :table LIMIT 1');
$stmt->execute(['table' => $table]);
return (bool)$stmt->fetchColumn();
}
private function hasColumn(string $table, string $column): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = :table AND column_name = :column LIMIT 1');
$stmt->execute([
'table' => $table,
'column' => $column,
]);
return (bool)$stmt->fetchColumn();
}
}

0
src/App/Config.php Normal file → Executable file
View File

0
src/App/Crypto.php Normal file → Executable file
View File

0
src/App/Database.php Normal file → Executable file
View File

0
src/App/Flash.php Normal file → Executable file
View File

0
src/App/I18n.php Normal file → Executable file
View File

0
src/App/Mailer.php Normal file → Executable file
View File

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App;
final class ProfileSettings
{
private bool $schemaEnsured = false;
private array $columnCache = [];
public function __construct(private \PDO $pdo)
{
}
public function ensureSchema(): void
{
if ($this->schemaEnsured) {
return;
}
if (!$this->hasColumn('user_profiles', 'location_tracking_preference')) {
$this->pdo->exec(
"ALTER TABLE user_profiles
ADD COLUMN location_tracking_preference ENUM('disabled','prompt','enabled')
NOT NULL DEFAULT 'prompt'
AFTER lng"
);
$this->columnCache['user_profiles.location_tracking_preference'] = true;
}
$this->schemaEnsured = true;
}
public function getLocationTrackingPreference(int $userId): string
{
if ($userId <= 0) {
return 'prompt';
}
$this->ensureSchema();
$stmt = $this->pdo->prepare('SELECT location_tracking_preference FROM user_profiles WHERE user_id = :id LIMIT 1');
$stmt->execute(['id' => $userId]);
$value = (string)($stmt->fetchColumn() ?: 'prompt');
return in_array($value, ['disabled', 'prompt', 'enabled'], true) ? $value : 'prompt';
}
public function updateLocationTrackingPreference(int $userId, string $preference): void
{
if ($userId <= 0) {
return;
}
$this->ensureSchema();
$preference = in_array($preference, ['disabled', 'prompt', 'enabled'], true) ? $preference : 'prompt';
$stmt = $this->pdo->prepare('UPDATE user_profiles SET location_tracking_preference = :pref, updated_at = NOW() WHERE user_id = :id');
$stmt->execute([
'pref' => $preference,
'id' => $userId,
]);
}
private function hasColumn(string $table, string $column): bool
{
$cacheKey = $table . '.' . $column;
if (array_key_exists($cacheKey, $this->columnCache)) {
return $this->columnCache[$cacheKey];
}
$stmt = $this->pdo->prepare("
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :tableName
AND COLUMN_NAME = :columnName
");
$stmt->execute([
'tableName' => $table,
'columnName' => $column,
]);
return $this->columnCache[$cacheKey] = ((int)$stmt->fetchColumn() > 0);
}
}

0
src/App/Request.php Normal file → Executable file
View File

0
src/App/Search.php Normal file → Executable file
View File

0
src/App/SessionManager.php Normal file → Executable file
View File

0
src/helpers.php Normal file → Executable file
View File

0
tools/.gitkeep Normal file → Executable file
View File