Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb16827fdf | |||
| 751b6996c4 | |||
| 1176b98522 | |||
| 60998777a4 | |||
| 257f9af358 | |||
| b9026c7808 | |||
| b9673814b5 | |||
| 3ff5767900 | |||
| 57e197755e | |||
| e39a483aff | |||
| d682d1dd2b | |||
| f614de8758 | |||
| 9e4570da45 | |||
| e23b6b9813 | |||
| 28b7a9b4aa | |||
| e010a1466c | |||
| 6fae7246ff | |||
| 48ab4ab922 | |||
| a2a8d9c7ba | |||
| fb7c807f2a | |||
| a586d7cf0e | |||
| 429258156a | |||
| dca3ce93e6 |
115
.gitea/workflows/deploy.yml
Normal 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
73
.projektstructure.txt
Executable 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
api/.gitkeep
Normal file → Executable file
0
config/.gitkeep
Normal file → Executable file
0
config/community.php
Normal file → Executable file
119
config/community_forums.php
Normal 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
0
config/fileload.php
Normal file → Executable file
0
config/prod/.gitkeep
Normal file → Executable file
0
config/prod/db.php
Normal file → Executable file
0
config/prod/domaindata.php
Normal file → Executable file
0
config/prod/emailtemplates.php
Normal file → Executable file
0
config/prod/settings.php
Normal file → Executable file
0
config/staging/.gitkeep
Normal file → Executable file
0
config/staging/db.php
Normal file → Executable file
0
config/staging/domaindata.php
Normal file → Executable file
0
config/staging/emailtemplates.php
Normal file → Executable file
0
config/staging/settings.php
Normal file → Executable file
0
debug/.gitkeep
Normal file → Executable file
0
partials/.gitkeep
Normal file → Executable file
221
partials/landing/account/community-admin.php
Normal 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
@@ -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
0
partials/landing/account/register.php
Normal file → Executable file
0
partials/landing/account/verify.php
Normal file → Executable 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>
|
||||||
|
|||||||
31
partials/landing/community/sidebar.php
Normal 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>
|
||||||
@@ -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
@@ -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 funktioniert’s</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
10
partials/structure/layout_end.php
Normal file → Executable 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
@@ -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
@@ -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 ?>&rec=1"
|
|
||||||
style="border:0;" alt="" />
|
|
||||||
</p>
|
|
||||||
</noscript>
|
|
||||||
<!-- End Matomo -->
|
|
||||||
|
|||||||
12
partials/structure/nav.php
Normal file → Executable 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
0
public/.htaccess
Normal file → Executable file
0
public/assets/bilder/404.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 144 KiB |
0
public/assets/bilder/404portrait.jpg
Normal file → Executable 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
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
0
public/assets/bilder/email/banner_passwordreset.jpg
Normal file → Executable 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
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
0
public/assets/bilder/email/logo_mail.png
Normal file → Executable file
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
0
public/assets/bilder/logo_female.png
Normal file → Executable file
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
0
public/assets/bilder/logo_male.png
Normal file → Executable file
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
0
public/assets/bilder/welcome.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
157
public/assets/css/app.css
Normal file → Executable 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
0
public/assets/fonts/KidsHandwriting-Regular.woff
Normal file → Executable file
0
public/assets/fonts/KidsHandwriting-Regular.woff2
Normal file → Executable file
445
public/assets/js/app.js
Normal file → Executable 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
@@ -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
|
||||||
|
|||||||
38
public/page/api/location-preference.php
Normal 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']);
|
||||||
|
}
|
||||||
4
public/page/community-admin.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
tpl('community-admin', 'landing', 'account');
|
||||||
0
public/page/community.php
Normal file → Executable file
0
public/page/community_thread.php
Normal file → Executable file
0
public/page/dashboard.php
Normal file → Executable file
0
public/page/debug.php
Normal file → Executable file
26
public/page/impressum.php
Normal 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
0
public/page/login.php
Normal file → Executable file
0
public/page/logout.php
Normal file → Executable file
0
public/page/register.php
Normal file → Executable file
0
public/page/reset.php
Normal file → Executable file
0
public/page/retool/emailtemplate_bridge.php
Normal file → Executable file
0
public/page/retool/emailtemplate_sender.php
Normal file → Executable file
0
public/page/search.php
Normal file → Executable file
102
public/page/staging-users.php
Normal 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
@@ -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
95
schema.sql
Normal file → Executable 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
67
src/App/AccountPages.php
Normal file → Executable 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
0
src/App/Assets.php
Normal file → Executable file
0
src/App/Auth.php
Normal file → Executable file
354
src/App/Community.php
Normal file → Executable 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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
154
src/App/CommunityMigration.php
Normal 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
0
src/App/Crypto.php
Normal file → Executable file
0
src/App/Database.php
Normal file → Executable file
0
src/App/Flash.php
Normal file → Executable file
0
src/App/I18n.php
Normal file → Executable file
0
src/App/Mailer.php
Normal file → Executable file
83
src/App/ProfileSettings.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||