Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e800d4839 | |||
| 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
115
.projectstructure.txt
Normal file
@@ -0,0 +1,115 @@
|
||||
Papa-Kind-Treff – Projektstruktur und Pflegehinweise
|
||||
Stand: 2026-07-22
|
||||
|
||||
1. Zweck
|
||||
- Plattform für Väter mit Fokus auf lokale Treffen, Community/Forum, Mitgliederbereich und spätere Liveschaltung.
|
||||
- Sprache und Tonalität im Frontend: freundlich, direkt, nutzerzentriert.
|
||||
|
||||
2. Zentrale Einstiegspunkte
|
||||
- `public/index.php`
|
||||
Front-Controller, Routing auf `public/page/*`, Staging-Basic-Auth, Layout-Steuerung.
|
||||
- `partials/structure/layout_start.php`
|
||||
Globale Assets, Body-Datasets, Consent-UI, Navigation, globale Modale.
|
||||
- `partials/structure/layout_end.php`
|
||||
Footer und Footer-Assets.
|
||||
|
||||
3. Wichtige Verzeichnisse
|
||||
- `public/page/`
|
||||
Route-Dateien.
|
||||
Wichtige Seiten:
|
||||
- `index.php` Startseite
|
||||
- `search.php` Suche
|
||||
- `community.php` Community-Übersicht
|
||||
- `community_thread.php` Thread-Ansicht
|
||||
- `dashboard.php` Mitgliederbereich
|
||||
- `community-admin.php` Moderation/Admin
|
||||
- `impressum.php`, `datenschutz.php`, `ueber-uns.php`
|
||||
- `api/location-preference.php` API zum Speichern der Standortpräferenz
|
||||
- `partials/landing/main/`
|
||||
Startseiten-Blöcke.
|
||||
- `partials/landing/community/`
|
||||
Community-Templates inkl. Sidebar-Navigation.
|
||||
- `partials/landing/account/`
|
||||
Dashboard, Community-Admin, Login/Register.
|
||||
- `partials/structure/`
|
||||
Layout, Navigation, Footer, Matomo-Konfiguration.
|
||||
- `src/App/`
|
||||
Geschäftslogik und Services.
|
||||
|
||||
4. Relevante App-Klassen
|
||||
- `src/App/AccountPages.php`
|
||||
Dashboard-/Profil-Logik, Event-Anlage, Community-Bewerbung, Migration.
|
||||
- `src/App/Community.php`
|
||||
Boards, Threads, Posts, Forenstruktur, Punkte.
|
||||
- `src/App/CommunityAccess.php`
|
||||
Rollen, Bewerbungen, Meldungen, Moderation, Restrictions.
|
||||
- `src/App/CommunityMigration.php`
|
||||
DB-Erweiterungen für Community-Funktionen.
|
||||
- `src/App/ProfileSettings.php`
|
||||
Persistente Profileinstellungen für Standortfreigabe.
|
||||
- `src/App/Search.php`
|
||||
Eventsuche inkl. Distanzfilter.
|
||||
|
||||
5. Frontend-Assets
|
||||
- `public/assets/js/app.js`
|
||||
Globale UI-Logik, Slider, Geolocation, Consent-Manager, Matomo-Opt-in.
|
||||
- `public/assets/css/app.css`
|
||||
Globales UI-Styling inkl. Community, Footer, Consent.
|
||||
- `public/assets/css/styles.css`
|
||||
Basis-Styles.
|
||||
|
||||
6. Aktueller Funktionsstand
|
||||
- Startseite mit Hero, Suche, neueste Treffen, Community-Vorschau, Mitgliederbereich.
|
||||
- Hauptnavigation aktuell reduziert auf `Home`, `Suche`, `Community`.
|
||||
- Für eingeloggte Nutzer gibt es rechts ein Profil-Dropdown mit `Übersicht`, `Mitgliederbereich`, `Abmelden`.
|
||||
- Kein eigener Top-Level-Punkt `Termine`; Event-Fokus liegt derzeit auf Startseite und Suche.
|
||||
- Community mit Kategorien, Boards, Thread-Liste, Thread-Ansicht, Rollen-/Moderationslogik.
|
||||
- Dashboard mit Profil, Kinder, eigene Events, Community-Status.
|
||||
- Community-Admin-Bereich für Bewerbungen, Meldungen, Rollen und Migration.
|
||||
- Impressum, Datenschutz-&-Cookies-Seite und Über-uns vorhanden.
|
||||
- Standortsortierung auf der Startseite für Treffen in der Nähe.
|
||||
- Standortpräferenz im Profil:
|
||||
- `disabled`
|
||||
- `prompt`
|
||||
- `enabled`
|
||||
- Consent-Manager:
|
||||
- notwendige Cookies immer aktiv
|
||||
- `analytics` für Matomo
|
||||
- `external_services` für Standort/Karten/Leaflet/Nominatim
|
||||
|
||||
7. Rollenmodell Community
|
||||
- normaler Nutzer
|
||||
- schreiben, antworten, bewerten, melden
|
||||
- `forum_admin`
|
||||
- Themen/Beiträge bearbeiten und löschen
|
||||
- Meldungen prüfen
|
||||
- Community-Schreibsperren setzen
|
||||
- `site_admin`
|
||||
- alles wie `forum_admin`
|
||||
- Bewerbungen prüfen
|
||||
- Community-Migration
|
||||
- `owner`
|
||||
- alles wie `site_admin`
|
||||
- Rollen vergeben/entziehen
|
||||
|
||||
8. Externe Dienste und rechtlich relevante Punkte
|
||||
- Matomo (`matomo.my-statistics.info`) nur nach Consent `analytics`.
|
||||
- Leaflet via `unpkg.com` nur nach Consent `external_services`.
|
||||
- OpenStreetMap Nominatim für Geocoding/Reverse-Geocoding nur nach Consent `external_services` bei Browserfunktionen.
|
||||
- Browser-Geolocation und lokale Standortspeicherung nur nach Consent `external_services` und zusätzlicher Standortpräferenz.
|
||||
- Jede neue Einbindung von Cookies, LocalStorage, SessionStorage, Tracking, externen Skripten, APIs oder Drittanbietern muss:
|
||||
- technisch im Consent-Manager berücksichtigt werden
|
||||
- in `README.md` dokumentiert werden
|
||||
- in dieser Datei vermerkt werden
|
||||
- in den rechtlich notwendigen Hinweisen/Datenschutztexten ergänzt werden
|
||||
|
||||
9. Pflicht bei zukünftigen Änderungen
|
||||
- Bei jeder funktionalen Änderung immer mitprüfen und bei Bedarf aktualisieren:
|
||||
- `README.md`
|
||||
- `.projectstructure.txt`
|
||||
- `PROJECT_CONTEXT.md`
|
||||
- Bei neuen Cookies, Tracking-Mechanismen oder Drittanbietern zusätzlich:
|
||||
- Consent-Logik anpassen
|
||||
- rechtliche Texte/Hinweise ergänzen
|
||||
- Opt-in/Opt-out technisch verifizieren
|
||||
- Änderungen an Navigation, Nutzerführung oder Rollenanzeige ebenfalls in dieser Datei, `README.md` und `PROJECT_CONTEXT.md` mitführen.
|
||||
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.
|
||||
55
PROJECT_CONTEXT.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Papa-Kind-Treff – Chat-Kontext
|
||||
|
||||
Stand: 2026-07-22
|
||||
|
||||
## Kurzbeschreibung
|
||||
Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind lokale Treffen/Events, ein Community-Forum, Mitgliederprofile mit optionalen Kinderinfos und ein Moderations-/Adminbereich.
|
||||
|
||||
## Technischer Rahmen
|
||||
- Kein großes Framework, sondern eigener Front-Controller in `public/index.php`
|
||||
- Templates unter `partials/`
|
||||
- Geschäftslogik unter `src/App/`
|
||||
- MySQL/MariaDB-Schema in `schema.sql`
|
||||
- Globales Frontend vor allem über `public/assets/js/app.js` und `public/assets/css/app.css`
|
||||
|
||||
## Wichtige Produktentscheidungen
|
||||
- Die Ansprache im Frontend soll immer den aktuellen Nutzer direkt ansprechen.
|
||||
- Die Hauptnavigation ist bewusst knapp gehalten: `Home`, `Suche`, `Community`.
|
||||
- `Termine` ist aktuell kein eigener Hauptpunkt mehr; Events werden über Startseite und Suche erschlossen.
|
||||
- Die Community ist hierarchisch aufgebaut: Kategorien -> Boards -> Threads -> Posts.
|
||||
- Moderation ist getrennt vom normalen Community-Frontend in einem eigenen Adminbereich.
|
||||
- Standortfunktionen sind relevant für lokale Treffen und werden künftig weiter ausgebaut.
|
||||
|
||||
## Navigation / Konto
|
||||
- Eingeloggte Nutzer sehen rechts ein Profil-Menü statt einzelner `Dashboard`-/`Logout`-Buttons.
|
||||
- Das Profil-Menü enthält aktuell `Übersicht`, `Mitgliederbereich`, `Abmelden`.
|
||||
- Debug ist kein Menüpunkt und erscheint nur als Floating-Käfer für `site_admin` bei aktivem Debug-Modus.
|
||||
|
||||
## Community und Rechte
|
||||
- dekorative Ränge über Punkte
|
||||
- Moderationsrollen:
|
||||
- `forum_admin`
|
||||
- `site_admin`
|
||||
- `owner`
|
||||
- Bewerbungen für Moderation sind vorgesehen bzw. umgesetzt.
|
||||
|
||||
## Recht / Consent
|
||||
- Nicht notwendige Funktionen laufen nicht automatisch los.
|
||||
- Es gibt einen Consent-Manager mit Kategorien:
|
||||
- `analytics`
|
||||
- `external_services`
|
||||
- Matomo wird nur nach Analyse-Einwilligung geladen.
|
||||
- Externe Karten-/Standortdienste und lokale Standortspeicherung hängen an `external_services`.
|
||||
- Zusätzlich gibt es eine Standortpräferenz im Profil:
|
||||
- `disabled`
|
||||
- `prompt`
|
||||
- `enabled`
|
||||
- Es gibt eine eigene Seite `/datenschutz` für Datenschutz- und Cookie-Hinweise sowie einen Footer-Link auf die Consent-Einstellungen.
|
||||
|
||||
## Doku-Regel
|
||||
Bei jeder künftigen Änderung müssen mindestens diese Dateien mitgeprüft werden:
|
||||
- `README.md`
|
||||
- `.projectstructure.txt`
|
||||
- `PROJECT_CONTEXT.md`
|
||||
|
||||
Wenn Cookies, LocalStorage, SessionStorage, Tracking, Geolocation oder Drittanbieter neu hinzukommen oder geändert werden, müssen zusätzlich Consent und rechtliche Hinweise angepasst werden.
|
||||
136
README.md
@@ -1,93 +1,67 @@
|
||||
# emailtemplate
|
||||
# Papa-Kind-Treff
|
||||
|
||||
Stand: 2026-07-22
|
||||
|
||||
Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale Treffen, Community-Austausch und einen geschützten Mitgliederbereich.
|
||||
|
||||
## Getting started
|
||||
## Produktumfang
|
||||
- lokale Events und Treffen finden
|
||||
- Community/Forum mit Kategorien, Boards, Threads und Antworten
|
||||
- Mitgliederbereich für Profil, optionale Kinderinfos und eigene Termine
|
||||
- Community-Moderation mit Rollenmodell
|
||||
|
||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
||||
## Aktueller Stand
|
||||
- Startseite mit Hero, Suche, Event-Karussell, Community-Vorschau und Mitgliederbereichs-Block
|
||||
- Community mit Board-Navigation und separater Thread-Ansicht
|
||||
- Community-Admin-Bereich für Bewerbungen, Meldungen, Rollen und Migration
|
||||
- Impressum, Datenschutz-&-Cookies-Seite und Über-uns
|
||||
- standortbasierte Sortierung für die neuesten Treffen
|
||||
- Standortpräferenz im Profil
|
||||
- Consent-Manager für Analyse und externe Dienste
|
||||
- Navigation aktuell mit `Home`, `Suche`, `Community`
|
||||
- eingeloggte Nutzer sehen rechts ein Profil-Menü statt separater `Dashboard`-/`Logout`-Buttons
|
||||
- Debug-Floating-Button nur für `site_admin` im Debug-Modus
|
||||
|
||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
||||
## Technik
|
||||
- Einstieg: `public/index.php`
|
||||
- Templates: `partials/`
|
||||
- App-Logik: `src/App/`
|
||||
- Assets: `public/assets/`
|
||||
- Datenbankschema: `schema.sql`
|
||||
|
||||
## Add your files
|
||||
## Rollen
|
||||
- `forum_admin`
|
||||
- `site_admin`
|
||||
- `owner`
|
||||
|
||||
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
||||
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
|
||||
Die Rechteverteilung und Community-Logik sind in `src/App/CommunityAccess.php` abgebildet.
|
||||
|
||||
```
|
||||
cd existing_repo
|
||||
git remote add origin https://gitlab.int.kusche.berlin/freemium_projects/papa-kind-treff.git
|
||||
git branch -M main
|
||||
git push -uf origin main
|
||||
```
|
||||
## Consent und rechtlich relevante Integrationen
|
||||
Der aktuelle Code nutzt oder kann nutzen:
|
||||
- notwendige Session-/Client-Cookies
|
||||
- Matomo (`analytics`)
|
||||
- Browser-Geolocation (`external_services`)
|
||||
- lokale Standortspeicherung via Cookie, `localStorage`, `sessionStorage` (`external_services`)
|
||||
- Leaflet von `unpkg.com` (`external_services`)
|
||||
- OpenStreetMap Nominatim (`external_services`)
|
||||
|
||||
## Integrate with your tools
|
||||
Wichtig:
|
||||
- Nicht notwendige Analyse- und Drittanbieterfunktionen dürfen erst nach Einwilligung aktiv werden.
|
||||
- Änderungen an Cookies, Tracking oder Drittanbietern müssen immer auch in Consent und rechtlichen Hinweisen nachgezogen werden.
|
||||
|
||||
- [ ] [Set up project integrations](https://gitlab.int.kusche.berlin/emailtemplate/emailtemplate/-/settings/integrations)
|
||||
## Dokumentationspflicht bei Änderungen
|
||||
Bei jeder Änderung im Projekt immer mitprüfen und bei Bedarf aktualisieren:
|
||||
- `README.md`
|
||||
- `.projectstructure.txt`
|
||||
- `PROJECT_CONTEXT.md`
|
||||
|
||||
## Collaborate with your team
|
||||
Zusätzlich bei neuen Cookies, Tracking-Mechanismen oder Drittanbietern:
|
||||
- Consent-Manager anpassen
|
||||
- rechtliche Hinweise/Datenschutztexte ergänzen
|
||||
- technische Opt-in-/Opt-out-Logik prüfen
|
||||
|
||||
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
||||
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
||||
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
||||
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
||||
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
||||
|
||||
## Test and Deploy
|
||||
|
||||
Use the built-in continuous integration in GitLab.
|
||||
|
||||
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
|
||||
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
||||
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
||||
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
||||
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
||||
|
||||
***
|
||||
|
||||
# Editing this README
|
||||
|
||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
||||
|
||||
## Suggestions for a good README
|
||||
|
||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
||||
|
||||
## Name
|
||||
Papa-Kind-Treff
|
||||
|
||||
## Description
|
||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
||||
|
||||
## Badges
|
||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
||||
|
||||
## Visuals
|
||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
||||
|
||||
## Installation
|
||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
||||
|
||||
## Usage
|
||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
||||
|
||||
## Support
|
||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
||||
|
||||
## Roadmap
|
||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
||||
|
||||
## Contributing
|
||||
State if you are open to contributions and what your requirements are for accepting them.
|
||||
|
||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
||||
|
||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
||||
|
||||
## Authors and acknowledgment
|
||||
Show your appreciation to those who have contributed to the project.
|
||||
|
||||
## License
|
||||
For open source projects, say how it is licensed.
|
||||
|
||||
## Project status
|
||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
||||
## Dateien für neue Chats
|
||||
Für spätere Chats als Einstieg besonders wichtig:
|
||||
- `README.md`
|
||||
- `.projectstructure.txt`
|
||||
- `PROJECT_CONTEXT.md`
|
||||
|
||||
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>
|
||||
84
partials/landing/account/dashboard.php
Normal file → Executable file
@@ -29,7 +29,62 @@ $allowNoKidsChecked = $editEvent ? ((int)$editEvent['allow_kids'] === 0) : false
|
||||
</div>
|
||||
|
||||
<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="badge">Profil</div>
|
||||
<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>E-Mail: <?= htmlspecialchars($profile['email'], 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>Sprachen: <?= htmlspecialchars($profile['languages'], 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>
|
||||
<input id="pProf" name="profession" class="input" value="<?= htmlspecialchars($profile['profession'], ENT_QUOTES) ?>">
|
||||
</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">
|
||||
<label class="label" for="pAbout">Kurzvorstellung</label>
|
||||
<textarea id="pAbout" name="about" class="textarea" rows="3"><?= htmlspecialchars($profile['about'], ENT_QUOTES) ?></textarea>
|
||||
@@ -391,6 +462,11 @@ $allowNoKidsChecked = $editEvent ? ((int)$editEvent['allow_kids'] === 0) : false
|
||||
let map, marker;
|
||||
|
||||
function ensureLeaflet(callback) {
|
||||
if (!window.PKTConsent || !window.PKTConsent.has('external_services')) {
|
||||
alert('Für Karten und Adresssuche bitte zuerst die externen Dienste in den Cookie-Einstellungen erlauben.');
|
||||
window.PKTConsent?.openPreferences?.();
|
||||
return;
|
||||
}
|
||||
if (window.L) { callback(); return; }
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet';
|
||||
@@ -437,6 +513,7 @@ $allowNoKidsChecked = $editEvent ? ((int)$editEvent['allow_kids'] === 0) : false
|
||||
}
|
||||
|
||||
function reverseGeocode(lat, lng) {
|
||||
if (!window.PKTConsent || !window.PKTConsent.has('external_services')) return;
|
||||
fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lng)}`, {
|
||||
headers: { 'Accept-Language': 'de', 'User-Agent': 'papa-kind-treff/1.0' },
|
||||
})
|
||||
@@ -446,6 +523,11 @@ $allowNoKidsChecked = $editEvent ? ((int)$editEvent['allow_kids'] === 0) : false
|
||||
}
|
||||
|
||||
function geocodeAndPlace(query) {
|
||||
if (!window.PKTConsent || !window.PKTConsent.has('external_services')) {
|
||||
alert('Für Karten und Adresssuche bitte zuerst die externen Dienste in den Cookie-Einstellungen erlauben.');
|
||||
window.PKTConsent?.openPreferences?.();
|
||||
return;
|
||||
}
|
||||
fetch('https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=' + encodeURIComponent(query), {
|
||||
headers: { 'Accept-Language': 'de', 'User-Agent': 'papa-kind-treff/1.0' },
|
||||
})
|
||||
|
||||
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,98 +5,274 @@ $app = app();
|
||||
$pdo = $app->pdo();
|
||||
$userId = $_SESSION['user_id'] ?? null;
|
||||
$error = '';
|
||||
$info = '';
|
||||
$search = trim((string)($_GET['q'] ?? ''));
|
||||
$boardSlug = trim((string)($_GET['board'] ?? ''));
|
||||
|
||||
$communityCfg = require __DIR__ . '/../../../config/community.php';
|
||||
$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() : [];
|
||||
$selectedBoard = ($community && $boardSlug !== '') ? $community->getBoardBySlug($boardSlug) : null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) {
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
try {
|
||||
if ($action === 'thread_create') {
|
||||
if (!$userId) {
|
||||
$error = 'Bitte einloggen, um Fragen zu stellen.';
|
||||
} elseif ($community) {
|
||||
$title = trim((string)($_POST['title'] ?? ''));
|
||||
$body = trim((string)($_POST['body'] ?? ''));
|
||||
if ($title === '' || $body === '') {
|
||||
$error = 'Titel und Text sind erforderlich.';
|
||||
} else {
|
||||
$community->createThread((int)$userId, $title, $body);
|
||||
redirect('/community');
|
||||
throw new \RuntimeException('Bitte einloggen, um Themen zu erstellen.');
|
||||
}
|
||||
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">
|
||||
<div class="container">
|
||||
<p class="eyebrow">Community</p>
|
||||
<h1>Forum</h1>
|
||||
<?php if ($error): ?>
|
||||
<div class="toast-bar" style="border-color:#f87171; color:#991b1b;"><?= htmlspecialchars($error, ENT_QUOTES) ?></div>
|
||||
<div class="forum-hero">
|
||||
<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 class="flex between center-y" style="margin:14px 0; gap:12px; flex-wrap:wrap;">
|
||||
<form method="get" class="flex gap-8" style="flex-wrap:wrap; align-items:flex-end;">
|
||||
<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>
|
||||
<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">Neue Frage</button>
|
||||
<button class="btn" type="button" data-modal-open="modalThread">Neues Thema</button>
|
||||
<?php else: ?>
|
||||
<a class="btn ghost" href="/login">Login für neue Frage</a>
|
||||
<a class="btn" href="/login">Einloggen und schreiben</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</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; ?>
|
||||
|
||||
<?php if ($selectedBoard): ?>
|
||||
<div class="forum-shell">
|
||||
<?php
|
||||
$sidebarCategories = $forumCategories;
|
||||
$sidebarCurrentBoardSlug = (string)$selectedBoard['slug'];
|
||||
require __DIR__ . '/sidebar.php';
|
||||
?>
|
||||
<div class="forum-shell__main">
|
||||
<div class="forum-board">
|
||||
<div class="forum-board__head">
|
||||
<div>
|
||||
<h2><?= htmlspecialchars($selectedBoard['title'], ENT_QUOTES) ?></h2>
|
||||
<p class="muted"><?= htmlspecialchars((string)$selectedBoard['description'], ENT_QUOTES) ?></p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="stack gap-8" style="margin-top:10px;">
|
||||
<?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="card" style="padding:14px;">
|
||||
<div class="event__body">
|
||||
<div class="event__meta" style="flex-wrap:wrap; gap:8px;">
|
||||
<span><?= htmlspecialchars($t['created_at'], ENT_QUOTES) ?></span>
|
||||
<span><?= htmlspecialchars($t['display_name'] ?: 'Mitglied', ENT_QUOTES) ?></span>
|
||||
<span><?= htmlspecialchars($lvl['icon'] ?? '', ENT_QUOTES) ?> <?= htmlspecialchars($lvl['label'] ?? '', ENT_QUOTES) ?> (<?= number_format($pts,1) ?> Punkte)</span>
|
||||
<span>Beiträge: <?= (int)$t['user_posts'] + (int)$t['answers'] ?></span>
|
||||
<span>Antworten: <?= (int)$t['answers'] ?></span>
|
||||
<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>
|
||||
<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>
|
||||
<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): ?>
|
||||
<p class="muted">Keine Treffer.</p>
|
||||
<div class="forum-empty">In diesem Bereich gibt es noch keine Themen.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="forum-category-list">
|
||||
<?php foreach ($forumCategories as $category): ?>
|
||||
<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>
|
||||
</main>
|
||||
|
||||
<?php if ($userId): ?>
|
||||
<div class="modal" id="modalThread">
|
||||
<div class="panel">
|
||||
<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>
|
||||
</div>
|
||||
<form method="post" class="stack gap-10" style="margin-top:12px;">
|
||||
<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">
|
||||
<label class="label" for="fTitleModal">Frage/Titel</label>
|
||||
<input id="fTitleModal" name="title" class="input" required>
|
||||
</div>
|
||||
<div class="stack gap-6">
|
||||
<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 class="flex gap-12" style="flex-wrap:wrap;">
|
||||
<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>
|
||||
</form>
|
||||
</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();
|
||||
$userId = $_SESSION['user_id'] ?? null;
|
||||
$error = '';
|
||||
$info = '';
|
||||
$threadId = (int)($_GET['id'] ?? 0);
|
||||
$editPostId = (int)($_GET['edit_post'] ?? 0);
|
||||
|
||||
$communityCfg = require __DIR__ . '/../../../config/community.php';
|
||||
$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) {
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
try {
|
||||
if ($action === 'reply') {
|
||||
if (!$userId) {
|
||||
$error = 'Bitte einloggen, um zu antworten.';
|
||||
} elseif ($community) {
|
||||
throw new \RuntimeException('Bitte einloggen, um zu antworten.');
|
||||
}
|
||||
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 === '') {
|
||||
$error = 'Antwort darf nicht leer sein.';
|
||||
} else {
|
||||
throw new \RuntimeException('Antwort darf nicht leer sein.');
|
||||
}
|
||||
$community->createPost((int)$userId, $threadId, $body);
|
||||
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,42 +99,202 @@ if (!$thread) {
|
||||
echo "<p>Thread nicht gefunden.</p>";
|
||||
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">
|
||||
<div class="container">
|
||||
<p class="eyebrow">Community</p>
|
||||
<h1><?= htmlspecialchars($thread['title'], ENT_QUOTES) ?></h1>
|
||||
<p class="muted">Von <?= htmlspecialchars($thread['display_name'] ?: 'Mitglied', ENT_QUOTES) ?> · <?= htmlspecialchars($thread['created_at'], ENT_QUOTES) ?></p>
|
||||
<article class="card" style="margin-top:12px;">
|
||||
<div class="event__body">
|
||||
<p><?= nl2br(htmlspecialchars($thread['body'], ENT_QUOTES)) ?></p>
|
||||
<div class="forum-breadcrumbs">
|
||||
<a href="/">Home</a>
|
||||
<span>/</span>
|
||||
<a href="/community">Community</a>
|
||||
<?php if (!empty($thread['board_title'])): ?>
|
||||
<span>/</span>
|
||||
<a href="/community?board=<?= rawurlencode((string)$thread['board_slug']) ?>"><?= htmlspecialchars((string)$thread['board_title'], ENT_QUOTES) ?></a>
|
||||
<?php endif; ?>
|
||||
<span>/</span>
|
||||
<span><?= htmlspecialchars((string)$thread['title'], ENT_QUOTES) ?></span>
|
||||
</div>
|
||||
|
||||
<div class="forum-shell">
|
||||
<?php
|
||||
$sidebarCategories = $forumCategories;
|
||||
$sidebarCurrentBoardSlug = (string)($thread['board_slug'] ?? '');
|
||||
require __DIR__ . '/sidebar.php';
|
||||
?>
|
||||
<div class="forum-shell__main">
|
||||
<div class="forum-thread-head">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="toast-bar" style="margin-bottom:14px; border-color:#f87171; color:#991b1b;"><?= htmlspecialchars($error, ENT_QUOTES) ?></div>
|
||||
<?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>
|
||||
|
||||
<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 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>
|
||||
<p><?= nl2br(htmlspecialchars($p['body'], ENT_QUOTES)) ?></p>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$posts): ?>
|
||||
<p class="muted">Noch keine Antworten.</p>
|
||||
<div class="forum-empty">Noch keine Antworten.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="toast-bar" style="margin-top:12px; border-color:#f87171; color:#991b1b;"><?= htmlspecialchars($error, ENT_QUOTES) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($userId): ?>
|
||||
<form method="post" class="stack gap-12 card" style="margin-top:14px; padding:16px;">
|
||||
<?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>
|
||||
@@ -75,8 +302,58 @@ if (!$thread) {
|
||||
</div>
|
||||
<button class="btn" type="submit">Antwort senden</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<p class="muted" style="margin-top:12px;">Bitte <a href="/login">einloggen</a>, um zu antworten.</p>
|
||||
<div class="forum-login-note">Bitte <a href="/login">einloggen</a>, um zu antworten.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
205
partials/landing/main/home.php
Normal file → Executable file
@@ -3,10 +3,18 @@ $app = app();
|
||||
$flash = $app->flash()->get();
|
||||
|
||||
$eventsForJs = [];
|
||||
$threadsForJs = [];
|
||||
try {
|
||||
$pdo = $app->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();
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
foreach ($rows as $r) {
|
||||
@@ -29,9 +37,24 @@ try {
|
||||
'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) {
|
||||
$eventsForJs = [];
|
||||
$threadsForJs = [];
|
||||
}
|
||||
?>
|
||||
<main>
|
||||
@@ -43,19 +66,13 @@ try {
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="hero">
|
||||
<div class="container hero__grid">
|
||||
<div class="container hero__single">
|
||||
<div class="hero__text">
|
||||
<p class="eyebrow">Gemeinsam stark</p>
|
||||
<h1>Treffen für Väter – mit und ohne Kinder. Lokal organisiert.</h1>
|
||||
<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>
|
||||
<div class="hero__actions">
|
||||
<button class="btn">Kostenlos registrieren</button>
|
||||
<button class="btn ghost" id="scrollToEvents">Events in deiner Nähe</button>
|
||||
</div>
|
||||
<div class="hero__meta">
|
||||
<div class="chip inline">Events nur für Mitglieder</div>
|
||||
<div class="chip inline">Kinder optional mitbringen</div>
|
||||
<div class="chip inline">Echt lokale Gruppen</div>
|
||||
<h1>Knüpfe Kontakte und organisiere gemeinsame Zeit mit anderen Vätern.</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="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 hero__actions--center">
|
||||
<a class="btn" href="/register">Gleich kostenlos registrieren</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,28 +85,12 @@ try {
|
||||
.slider__track .event-card-small {min-width:240px; max-width:260px;}
|
||||
.slider__nav {min-width:44px;}
|
||||
</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">
|
||||
<div class="container">
|
||||
<p class="eyebrow">Schnellsuche</p>
|
||||
<h3>Passende Events finden</h3>
|
||||
<form id="quickSearchForm" class="grid grid-3" style="gap: 12px; align-items:flex-end;" action="/search" method="get">
|
||||
<div class="section__intro">
|
||||
<h2>Suche nach Treffen in deiner Nähe</h2>
|
||||
</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">
|
||||
<label class="label" for="qsQuery">Suchbegriff</label>
|
||||
<input id="qsQuery" name="q" class="input" placeholder="Titel, Thema, Beschreibung">
|
||||
@@ -111,117 +112,79 @@ try {
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn block" type="submit" style="width:100%;">Suchen</button>
|
||||
<div class="quicksearch-form__actions">
|
||||
<button class="btn" type="submit">Suchen</button>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section class="section alt" id="profil">
|
||||
<div class="container split">
|
||||
<div>
|
||||
<p class="eyebrow">Mitgliederbereich</p>
|
||||
<h2>Alles für Väter – Profile, Kinderinfos, Events.</h2>
|
||||
<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>
|
||||
<div class="grid grid-2 mt-2">
|
||||
<section class="container section" id="events">
|
||||
<div class="section__intro">
|
||||
<h2>Die neuesten Treffen</h2>
|
||||
</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="community-preview">
|
||||
<div class="container">
|
||||
<div class="section__intro">
|
||||
<h2>Tausch dich in der Community aus</h2>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section class="section alt" id="mitgliederbereich">
|
||||
<div class="container">
|
||||
<div class="section__intro">
|
||||
<h2>Der Mitgliederbereich ist der Ort, an dem du aus dem Überblick eine echte Nutzung machst.</h2>
|
||||
</div>
|
||||
<div class="grid grid-3 mt-2">
|
||||
<div class="surface border rounded p-4">
|
||||
<h3 class="mt-0">Profil</h3>
|
||||
<h3 class="mt-0">Dein 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>
|
||||
<li>Anzeigename, Sprachen, Interessen, kurzer Steckbrief.</li>
|
||||
<li>Geschützte Kontaktdaten, du steuerst wer was sehen darf.</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>
|
||||
<li>Alter, Geburtsdatum oder kurze Hinweise hinterlegen, wenn es dir bei der Planung hilft.</li>
|
||||
<li>Treffen besser einschätzen, wenn Aktivitäten zum Alter deiner Kinder passen sollen.</li>
|
||||
<li>Nur für dich sichtbar.</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 class="card privacy-card">
|
||||
<div class="badge">Community</div>
|
||||
<h3>Einfach starten</h3>
|
||||
<div class="surface border rounded p-4">
|
||||
<h3 class="mt-0">Deine Termine</h3>
|
||||
<ul class="list">
|
||||
<li>Registriere dich kostenfrei</li>
|
||||
<li>Profil ausfüllen, Kinder optional hinzufügen</li>
|
||||
<li>Events finden oder eigene Termine einstellen</li>
|
||||
<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>
|
||||
<p class="muted small">Alle Einstellungen nimmst du im Mitgliederbereich vor.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="container section" id="sicherheit">
|
||||
<div class="section__head">
|
||||
<div>
|
||||
<p class="eyebrow">Ablauf</p>
|
||||
<h2>So funktioniert’s</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-3">
|
||||
<div class="card step">
|
||||
<div class="step__icon">1</div>
|
||||
<h3>Profil anlegen</h3>
|
||||
<p class="muted small">Papa-Daten ausfüllen, Kinder optional. Sichtbarkeit pro Bereich einstellen.</p>
|
||||
</div>
|
||||
<div class="card step">
|
||||
<div class="step__icon">2</div>
|
||||
<h3>Events finden</h3>
|
||||
<p class="muted small">Suche nach Thema, Alter oder Region. Gäste sehen nur Basisinfos.</p>
|
||||
</div>
|
||||
<div class="card step">
|
||||
<div class="step__icon">3</div>
|
||||
<h3>Treffen planen</h3>
|
||||
<p class="muted small">Als Mitglied neue Events anlegen, andere einladen, Teilnahme verwalten.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section alt" id="faq">
|
||||
<div class="container split">
|
||||
<div>
|
||||
<p class="eyebrow">Noch Fragen?</p>
|
||||
<h2>FAQ</h2>
|
||||
<div class="faq">
|
||||
<details open>
|
||||
<summary>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>
|
||||
<script>
|
||||
window.__events = <?= json_encode($eventsForJs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||||
window.__threads = <?= json_encode($threadsForJs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||||
</script>
|
||||
<div class="modal" id="eventModal">
|
||||
<div class="panel">
|
||||
|
||||
10
partials/landing/search/search.php
Normal file → Executable file
@@ -140,6 +140,11 @@ if ($pdo && ($q !== '' || $loc !== '' || ($lat !== null && $lng !== null))) {
|
||||
}
|
||||
|
||||
function ensureLeaflet(cb) {
|
||||
if (!window.PKTConsent || !window.PKTConsent.has('external_services')) {
|
||||
alert('Für Karten- und Standortfunktionen bitte zuerst die externen Dienste in den Cookie-Einstellungen erlauben.');
|
||||
window.PKTConsent?.openPreferences?.();
|
||||
return;
|
||||
}
|
||||
if (window.L) { cb(); return; }
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet';
|
||||
@@ -188,6 +193,11 @@ if ($pdo && ($q !== '' || $loc !== '' || ($lat !== null && $lng !== null))) {
|
||||
});
|
||||
|
||||
btnGeo?.addEventListener('click', () => {
|
||||
if (!window.PKTConsent || !window.PKTConsent.has('external_services')) {
|
||||
alert('Für Standortfunktionen bitte zuerst die externen Dienste in den Cookie-Einstellungen erlauben.');
|
||||
window.PKTConsent?.openPreferences?.();
|
||||
return;
|
||||
}
|
||||
if (!navigator.geolocation) {
|
||||
alert('Geolocation wird nicht unterstützt.');
|
||||
return;
|
||||
|
||||
11
partials/structure/layout_end.php
Normal file → Executable file
@@ -1,3 +1,14 @@
|
||||
<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="/datenschutz">Datenschutz & Cookies</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'); ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
107
partials/structure/layout_start.php
Normal file → Executable file
@@ -13,6 +13,24 @@ if (!in_array($childGender, ['male', 'female', 'mixed'], true)) {
|
||||
}
|
||||
|
||||
$debugEnabled = defined('APP_DEBUG') && APP_DEBUG === true;
|
||||
$locationTrackingPreference = 'prompt';
|
||||
$showDebugTools = false;
|
||||
if (isset($_SESSION['user_id'])) {
|
||||
try {
|
||||
$pdo = $app->pdo();
|
||||
if ($pdo) {
|
||||
$profileSettings = new \App\ProfileSettings($pdo);
|
||||
$locationTrackingPreference = $profileSettings->getLocationTrackingPreference((int)$_SESSION['user_id']);
|
||||
$communityCfg = dirname(__DIR__, 2) . '/config/community.php';
|
||||
$communityConfig = file_exists($communityCfg) ? require $communityCfg : [];
|
||||
$communityAccess = new \App\CommunityAccess($pdo, $communityConfig);
|
||||
$showDebugTools = $debugEnabled && $communityAccess->hasRole((int)$_SESSION['user_id'], 'site_admin');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$locationTrackingPreference = 'prompt';
|
||||
$showDebugTools = false;
|
||||
}
|
||||
}
|
||||
$debugFiles = [];
|
||||
if ($debugEnabled) {
|
||||
$debugDir = __DIR__ . '/../../debug';
|
||||
@@ -57,11 +75,96 @@ if ($debugEnabled) {
|
||||
<?php asset_styles(); ?>
|
||||
<?php asset_scripts('header'); ?>
|
||||
</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('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 ($showDebugTools): ?>
|
||||
<style>
|
||||
.debug-fab { position: fixed; right: 18px; bottom: 18px; z-index: 200; background:#111827; color:#fff; border:none; border-radius: 999px; width: 52px; height:52px; display:flex; align-items:center; justify-content:center; font-size:24px; box-shadow:0 10px 30px rgba(0,0,0,0.2); cursor:pointer; }
|
||||
.debug-modal { position:fixed; inset:0; background: rgba(0,0,0,0.4); display:none; align-items:center; justify-content:center; z-index: 210; padding:16px; }
|
||||
|
||||
35
partials/structure/matomo.php
Normal file → Executable file
@@ -3,38 +3,13 @@
|
||||
<?php
|
||||
$primaryDomain = app_primary_domain();
|
||||
$fakecheckDomain = app_fakecheck_domain();
|
||||
|
||||
// Build allowed domains for tracking (hosts only, no paths)
|
||||
$matomoDomains = array_values(array_unique(array_filter([
|
||||
$primaryDomain ? '*.' . $primaryDomain : null,
|
||||
($fakecheckDomain && $fakecheckDomain !== $primaryDomain) ? '*.' . $fakecheckDomain : null,
|
||||
])));
|
||||
?>
|
||||
|
||||
<!-- Matomo -->
|
||||
<script>
|
||||
var _paq = window._paq = window._paq || [];
|
||||
_paq.push(["setDomains", <?= json_encode($matomoDomains, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>]);
|
||||
_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 -->
|
||||
<script type="application/json" id="matomoConfig"><?= json_encode([
|
||||
'url' => rtrim(MATOMO_URL, '/') . '/',
|
||||
'siteId' => (string) MATOMO_SITE_ID,
|
||||
'domains' => $matomoDomains,
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?></script>
|
||||
|
||||
56
partials/structure/nav.php
Normal file → Executable file
@@ -1,7 +1,27 @@
|
||||
<?php
|
||||
$app = app();
|
||||
$isLoggedIn = isset($_SESSION['user_id']);
|
||||
$isDebug = defined('APP_DEBUG') && APP_DEBUG === true;
|
||||
$displayName = 'Profil';
|
||||
$profileInitial = 'P';
|
||||
|
||||
if ($isLoggedIn) {
|
||||
try {
|
||||
$pdo = $app->pdo();
|
||||
if ($pdo) {
|
||||
$stmt = $pdo->prepare('SELECT display_name, first_name FROM user_profiles WHERE user_id = :id LIMIT 1');
|
||||
$stmt->execute(['id' => (int)$_SESSION['user_id']]);
|
||||
$profileRow = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
$displayName = trim((string)($profileRow['display_name'] ?? '')) ?: trim((string)($profileRow['first_name'] ?? '')) ?: 'Profil';
|
||||
$firstChar = mb_substr($displayName, 0, 1);
|
||||
if ($firstChar !== '') {
|
||||
$profileInitial = mb_strtoupper($firstChar);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$displayName = 'Profil';
|
||||
$profileInitial = 'P';
|
||||
}
|
||||
}
|
||||
?>
|
||||
<header class="site-header">
|
||||
<div class="container nav-row">
|
||||
@@ -13,20 +33,23 @@ $isDebug = defined('APP_DEBUG') && APP_DEBUG === true;
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav-links" aria-label="Hauptmenü">
|
||||
<a href="/">Start</a>
|
||||
<a href="/#events">Events</a>
|
||||
<a href="/">Home</a>
|
||||
<a href="/search">Suche</a>
|
||||
<a href="/community">Community</a>
|
||||
<a href="/#profil">Profil</a>
|
||||
<a href="/#faq">FAQ</a>
|
||||
</nav>
|
||||
<div class="nav-actions">
|
||||
<?php if ($isLoggedIn): ?>
|
||||
<a class="btn ghost" href="/dashboard">Dashboard</a>
|
||||
<a class="btn ghost" href="/logout">Logout</a>
|
||||
<?php if ($isDebug): ?>
|
||||
<a class="btn ghost" href="/debug">Debug</a>
|
||||
<?php endif; ?>
|
||||
<div class="profile-menu" data-profile-menu>
|
||||
<button class="profile-menu__trigger" type="button" aria-haspopup="menu" aria-expanded="false" data-profile-menu-trigger>
|
||||
<span class="profile-menu__avatar" aria-hidden="true"><?= htmlspecialchars($profileInitial, ENT_QUOTES) ?></span>
|
||||
<span class="profile-menu__name"><?= htmlspecialchars($displayName, ENT_QUOTES) ?></span>
|
||||
</button>
|
||||
<div class="profile-menu__dropdown" role="menu">
|
||||
<a href="/dashboard" role="menuitem">Übersicht</a>
|
||||
<a href="/dashboard" role="menuitem">Mitgliederbereich</a>
|
||||
<a href="/logout" role="menuitem">Abmelden</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<a class="btn ghost" href="/login">Anmelden</a>
|
||||
<a class="btn" href="/register">Kostenlos registrieren</a>
|
||||
@@ -35,18 +58,13 @@ $isDebug = defined('APP_DEBUG') && APP_DEBUG === true;
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-menu" id="mobileMenu">
|
||||
<a href="/">Start</a>
|
||||
<a href="/#events">Events</a>
|
||||
<a href="/">Home</a>
|
||||
<a href="/search">Suche</a>
|
||||
<a href="/community">Community</a>
|
||||
<a href="/#profil">Profil</a>
|
||||
<a href="/#faq">FAQ</a>
|
||||
<?php if ($isLoggedIn): ?>
|
||||
<a class="btn ghost" href="/dashboard">Dashboard</a>
|
||||
<a class="btn block" href="/logout">Logout</a>
|
||||
<?php if ($isDebug): ?>
|
||||
<a class="btn ghost block" href="/debug">Debug</a>
|
||||
<?php endif; ?>
|
||||
<a class="btn ghost" href="/dashboard">Übersicht</a>
|
||||
<a class="btn ghost" href="/dashboard">Mitgliederbereich</a>
|
||||
<a class="btn block" href="/logout">Abmelden</a>
|
||||
<?php else: ?>
|
||||
<a class="btn ghost" href="/login">Anmelden</a>
|
||||
<a class="btn block" href="/register">Kostenlos registrieren</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 |
169
public/assets/css/app.css
Normal file → Executable file
@@ -49,6 +49,15 @@ body {
|
||||
.nav-links a:hover { color: var(--color-primary); }
|
||||
|
||||
.nav-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.profile-menu { position: relative; }
|
||||
.profile-menu__trigger { display: inline-flex; align-items: center; gap: 10px; padding: 6px 10px 6px 6px; border: 1px solid var(--color-border); border-radius: 999px; background: #fff; color: var(--color-text); cursor: pointer; font: inherit; }
|
||||
.profile-menu__trigger:hover { border-color: var(--color-primary); }
|
||||
.profile-menu__avatar { width: 34px; height: 34px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; background: var(--color-primary); color: var(--color-primary-contrast); font-weight: 700; }
|
||||
.profile-menu__name { font-weight: 600; }
|
||||
.profile-menu__dropdown { position: absolute; top: calc(100% + 10px); right: 0; min-width: 220px; padding: 10px; display: none; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-md); box-shadow: var(--shadow-card); }
|
||||
.profile-menu.is-open .profile-menu__dropdown { display: grid; gap: 4px; }
|
||||
.profile-menu__dropdown a { display: block; padding: 10px 12px; border-radius: 10px; font-weight: 600; color: var(--color-text); }
|
||||
.profile-menu__dropdown a:hover { background: #f8f4ec; color: var(--color-primary); }
|
||||
.menu-toggle { display: none; background: transparent; border: 1px solid var(--color-border); padding: 8px 10px; border-radius: var(--radius-sm); }
|
||||
.mobile-menu { display: none; padding: 12px 16px; border-top: 1px solid var(--color-border); background: #fff; }
|
||||
.mobile-menu a, .mobile-menu button { display: block; width: 100%; text-align: left; margin-bottom: 10px; }
|
||||
@@ -56,7 +65,8 @@ body {
|
||||
@media (max-width: 900px){
|
||||
.nav-links { display: none; }
|
||||
.menu-toggle { display: inline-flex; }
|
||||
.nav-actions .btn { display: none; }
|
||||
.nav-actions .btn,
|
||||
.profile-menu { display: none; }
|
||||
.mobile-menu.open { display: block; }
|
||||
}
|
||||
|
||||
@@ -66,19 +76,24 @@ body {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.hero__grid { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 32px; align-items: center; }
|
||||
.hero__text h1 { margin: 12px 0 10px 0; font-size: clamp(28px, 4vw, 42px); line-height: 1.1; }
|
||||
.hero__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__copy { margin: 14px 0 0; color: var(--color-text); }
|
||||
.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__card { padding: 20px; background: #fff; border:1px solid var(--color-border); box-shadow: var(--shadow-card); }
|
||||
|
||||
.eyebrow { text-transform: uppercase; letter-spacing: 1px; font-size: 12px; color: var(--color-muted); margin: 0; }
|
||||
.lede { margin: 0; }
|
||||
.muted.small { font-size: 13px; }
|
||||
|
||||
.section { padding: 64px 0; }
|
||||
.section.alt { background: #ffffff; border-block: 1px solid var(--color-border); }
|
||||
.section__head { display:flex; justify-content:space-between; align-items:flex-start; gap: 16px; flex-wrap: wrap; }
|
||||
.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; }
|
||||
@media (max-width: 960px){ .split, .hero__grid { grid-template-columns: 1fr; } }
|
||||
@@ -120,12 +135,129 @@ body {
|
||||
.event__meta { display:flex; gap: 12px; flex-wrap: wrap; font-size: 13px; color: var(--color-muted); }
|
||||
.event__tags { display:flex; gap: 6px; flex-wrap: wrap; }
|
||||
.event__access { font-size: 12px; color: var(--color-highlight); font-weight: 700; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.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); }
|
||||
.input, .select { border-radius: var(--radius-sm); border-color: var(--color-border); background: #fff; }
|
||||
.input:focus, .select:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(52,72,90,0.14); }
|
||||
|
||||
.cta-card { background: linear-gradient(135deg, #fdf4e0, #ffffff); }
|
||||
.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; }
|
||||
.between { justify-content: space-between; }
|
||||
@@ -133,10 +265,39 @@ body {
|
||||
.gap-12 { gap: 12px; }
|
||||
.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){
|
||||
.nav-row { padding: 12px 0; }
|
||||
.hero { padding: 40px 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 */
|
||||
|
||||
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
624
public/assets/js/app.js
Normal file → Executable file
@@ -2,8 +2,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const body = document.body;
|
||||
const isLoggedIn = body.dataset.auth === '1';
|
||||
const childGender = body.dataset.childGender || '';
|
||||
const locationPreference = body.dataset.locationPreference || 'prompt';
|
||||
const header = document.querySelector('.site-header');
|
||||
const headerSentinel = document.getElementById('headerSentinel');
|
||||
const matomoConfigNode = document.getElementById('matomoConfig');
|
||||
const consentStateKey = 'pkt_cookie_consent';
|
||||
let matomoLoaded = false;
|
||||
|
||||
// Logo-Logik
|
||||
const pickLogo = (gender) => {
|
||||
@@ -40,6 +44,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
mobileMenu?.classList.toggle('open');
|
||||
});
|
||||
});
|
||||
const profileMenu = document.querySelector('[data-profile-menu]');
|
||||
const profileMenuTrigger = document.querySelector('[data-profile-menu-trigger]');
|
||||
profileMenuTrigger?.addEventListener('click', () => {
|
||||
const next = !profileMenu?.classList.contains('is-open');
|
||||
profileMenu?.classList.toggle('is-open', next);
|
||||
profileMenuTrigger.setAttribute('aria-expanded', next ? 'true' : 'false');
|
||||
});
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!profileMenu || !profileMenuTrigger) return;
|
||||
if (profileMenu.contains(event.target)) return;
|
||||
profileMenu.classList.remove('is-open');
|
||||
profileMenuTrigger.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
// Scroll zu Events
|
||||
const scrollBtn = document.getElementById('scrollToEvents');
|
||||
@@ -51,12 +68,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Events from backend (injected on page); fallback to empty
|
||||
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 = {
|
||||
sliderTrack: document.getElementById('eventSlider'),
|
||||
sliderViewport: document.querySelector('.slider__viewport'),
|
||||
sliderPrev: document.querySelector('[data-slider-prev]'),
|
||||
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'),
|
||||
modalBody: document.getElementById('eventModalBody'),
|
||||
modalTitle: document.getElementById('eventModalTitle'),
|
||||
@@ -66,43 +92,337 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
quickLat: document.getElementById('qsLat'),
|
||||
quickLng: document.getElementById('qsLng'),
|
||||
quickGeo: document.getElementById('quickGeo'),
|
||||
eventsSection: document.getElementById('events'),
|
||||
locationPreferenceModal: document.getElementById('locationPreferenceModal'),
|
||||
consentBanner: document.getElementById('cookieConsentBanner'),
|
||||
consentModal: document.getElementById('cookieConsentModal'),
|
||||
consentAnalytics: document.getElementById('consentAnalytics'),
|
||||
consentExternalServices: document.getElementById('consentExternalServices'),
|
||||
};
|
||||
let effectiveLocationPreference = locationPreference;
|
||||
let consentState = null;
|
||||
|
||||
const fmtDate = (iso) => {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const now = () => new Date();
|
||||
|
||||
const deleteCookie = (name) => {
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax`;
|
||||
};
|
||||
|
||||
const readConsent = () => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(consentStateKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return {
|
||||
necessary: true,
|
||||
analytics: Boolean(parsed.analytics),
|
||||
external_services: Boolean(parsed.external_services),
|
||||
updatedAt: parsed.updatedAt || null,
|
||||
};
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeConsent = (nextState) => {
|
||||
consentState = {
|
||||
necessary: true,
|
||||
analytics: Boolean(nextState.analytics),
|
||||
external_services: Boolean(nextState.external_services),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
window.localStorage.setItem(consentStateKey, JSON.stringify(consentState));
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
window.PKTConsent = window.PKTConsent || {};
|
||||
window.PKTConsent.get = () => consentState;
|
||||
window.PKTConsent.has = (category) => {
|
||||
if (category === 'necessary') return true;
|
||||
return Boolean(consentState?.[category]);
|
||||
};
|
||||
};
|
||||
|
||||
const closeConsentModal = () => {
|
||||
el.consentModal?.classList.remove('open');
|
||||
};
|
||||
|
||||
const openConsentModal = () => {
|
||||
if (el.consentAnalytics) el.consentAnalytics.checked = Boolean(consentState?.analytics);
|
||||
if (el.consentExternalServices) el.consentExternalServices.checked = Boolean(consentState?.external_services);
|
||||
el.consentModal?.classList.add('open');
|
||||
};
|
||||
|
||||
const deleteMatomoCookies = () => {
|
||||
['_pk_id', '_pk_ses', '_pk_ref', '_pk_cvar', 'mtm_consent', 'mtm_consent_removed'].forEach((prefix) => {
|
||||
deleteCookie(prefix);
|
||||
const hostParts = window.location.hostname.split('.');
|
||||
while (hostParts.length > 1) {
|
||||
document.cookie = `${prefix}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${hostParts.join('.')}; SameSite=Lax`;
|
||||
hostParts.shift();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadMatomo = () => {
|
||||
if (matomoLoaded || !matomoConfigNode || !consentState?.analytics) return;
|
||||
try {
|
||||
const cfg = JSON.parse(matomoConfigNode.textContent || '{}');
|
||||
if (!cfg.url || !cfg.siteId) return;
|
||||
const _paq = window._paq = window._paq || [];
|
||||
_paq.push(['setDomains', Array.isArray(cfg.domains) ? cfg.domains : []]);
|
||||
_paq.push(['trackPageView']);
|
||||
_paq.push(['enableLinkTracking']);
|
||||
_paq.push(['setTrackerUrl', cfg.url + 'matomo.php']);
|
||||
_paq.push(['setSiteId', cfg.siteId]);
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = cfg.url + 'matomo.js';
|
||||
document.head.appendChild(script);
|
||||
matomoLoaded = true;
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const showConsentBannerIfNeeded = () => {
|
||||
if (!consentState && el.consentBanner) {
|
||||
el.consentBanner.removeAttribute('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
const applyConsent = (nextState, options = {}) => {
|
||||
const previous = consentState;
|
||||
writeConsent(nextState);
|
||||
if (!consentState.external_services) {
|
||||
clearStoredLocation();
|
||||
}
|
||||
if (!consentState.analytics) {
|
||||
deleteMatomoCookies();
|
||||
} else {
|
||||
loadMatomo();
|
||||
}
|
||||
el.consentBanner?.setAttribute('hidden', 'hidden');
|
||||
closeConsentModal();
|
||||
|
||||
if (previous && options.reloadOnChange !== false) {
|
||||
const analyticsChanged = previous.analytics !== consentState.analytics;
|
||||
const externalChanged = previous.external_services !== consentState.external_services;
|
||||
if (analyticsChanged || externalChanged) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const requireExternalServicesConsent = (message = 'Für diese Funktion werden externe Dienste benötigt.') => {
|
||||
if (consentState?.external_services) {
|
||||
return true;
|
||||
}
|
||||
alert(message);
|
||||
openConsentModal();
|
||||
return false;
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
deleteCookie('pkt_user_location');
|
||||
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 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 guest = !isLoggedIn;
|
||||
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 `
|
||||
<article class="card event-card-small">
|
||||
<div class="muted small">${fmtDate(item.startsAt)}</div>
|
||||
<h3>${item.title}</h3>
|
||||
<p class="muted">${item.teaser}</p>
|
||||
<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;">
|
||||
<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>
|
||||
</article>`;
|
||||
};
|
||||
|
||||
const renderSlider = () => {
|
||||
const renderSlider = (location = null) => {
|
||||
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>';
|
||||
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 => {
|
||||
btn.addEventListener('click', () => {
|
||||
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;
|
||||
el.modalTitle.textContent = ev.title;
|
||||
el.modalBody.innerHTML = `
|
||||
@@ -110,6 +430,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<p class="muted">${ev.teaser}</p>
|
||||
<p>${ev.description}</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>Sichtbarkeit:</strong> ${ev.visibility === 'public' ? 'Öffentlich' : 'Nur Mitglieder'}</p>
|
||||
`;
|
||||
@@ -118,14 +440,210 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
};
|
||||
|
||||
const scrollSlider = (dir) => {
|
||||
if (!el.sliderViewport) return;
|
||||
const amount = dir === 'next' ? 320 : -320;
|
||||
el.sliderViewport.scrollBy({ left: amount, behavior: 'smooth' });
|
||||
const renderThreadCard = (item) => {
|
||||
const preview = item.body.length > 170 ? `${item.body.slice(0, 170)}…` : item.body;
|
||||
return `
|
||||
<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'));
|
||||
el.sliderNext?.addEventListener('click', () => scrollSlider('next'));
|
||||
const renderThreadSlider = () => {
|
||||
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;
|
||||
}
|
||||
if (!requireExternalServicesConsent('Für standortbezogene Treffen werden Standort- und Kartendienste erst nach deiner Einwilligung aktiviert.')) {
|
||||
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
|
||||
const geocode = async (query) => {
|
||||
@@ -139,17 +657,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
el.quickGeo?.addEventListener('click', () => {
|
||||
if (!requireExternalServicesConsent('Für die Standortermittlung benötigen wir deine Einwilligung zu externen Diensten und Standortfunktionen.')) {
|
||||
return;
|
||||
}
|
||||
if (!navigator.geolocation) {
|
||||
alert('Geolocation wird nicht unterstützt.');
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
if (el.quickLat && el.quickLng) {
|
||||
el.quickLat.value = pos.coords.latitude.toFixed(6);
|
||||
el.quickLng.value = pos.coords.longitude.toFixed(6);
|
||||
if (el.quickLoc) el.quickLoc.value = 'Mein Standort';
|
||||
}
|
||||
persistLocation({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
label: 'Mein Standort',
|
||||
}, effectiveLocationPreference === 'enabled' ? 'persistent' : 'session');
|
||||
renderSlider(getStoredLocation());
|
||||
},
|
||||
() => alert('Standort konnte nicht ermittelt werden.')
|
||||
);
|
||||
@@ -181,6 +703,70 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
btn.addEventListener('click', () => el.modal?.classList.remove('open'));
|
||||
});
|
||||
el.modal?.addEventListener('click', (e) => { if (e.target === el.modal) el.modal.classList.remove('open'); });
|
||||
|
||||
renderSlider();
|
||||
document.querySelectorAll('[data-consent-open]').forEach(btn => {
|
||||
btn.addEventListener('click', openConsentModal);
|
||||
});
|
||||
document.querySelectorAll('[data-consent-close]').forEach(btn => {
|
||||
btn.addEventListener('click', closeConsentModal);
|
||||
});
|
||||
document.querySelectorAll('[data-consent-necessary]').forEach(btn => {
|
||||
btn.addEventListener('click', () => applyConsent({ analytics: false, external_services: false }));
|
||||
});
|
||||
document.querySelector('[data-consent-accept-all]')?.addEventListener('click', () => {
|
||||
applyConsent({ analytics: true, external_services: true });
|
||||
});
|
||||
document.querySelector('[data-consent-save]')?.addEventListener('click', () => {
|
||||
applyConsent({
|
||||
analytics: Boolean(el.consentAnalytics?.checked),
|
||||
external_services: Boolean(el.consentExternalServices?.checked),
|
||||
});
|
||||
});
|
||||
el.consentModal?.addEventListener('click', (e) => {
|
||||
if (e.target === el.consentModal) closeConsentModal();
|
||||
});
|
||||
document.addEventListener('pkt:open-consent', openConsentModal);
|
||||
|
||||
document.querySelectorAll('[data-nav-events]').forEach(link => {
|
||||
link.addEventListener('click', async () => {
|
||||
if (window.location.pathname === '/' && window.location.hash === '#events') {
|
||||
await ensureEventsLocation();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
consentState = readConsent();
|
||||
if (consentState?.analytics) {
|
||||
loadMatomo();
|
||||
}
|
||||
showConsentBannerIfNeeded();
|
||||
|
||||
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 === '/' && consentState?.external_services) {
|
||||
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) {
|
||||
$authUser = getenv('STAGING_AUTH_USER') ?: 'staging';
|
||||
$authPass = getenv('STAGING_AUTH_PASS') ?: 'staging123';
|
||||
$remoteUser = $_SERVER['REMOTE_USER'] ?? null;
|
||||
$user = $_SERVER['PHP_AUTH_USER'] ?? 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('HTTP/1.0 401 Unauthorized');
|
||||
echo 'Unauthorized';
|
||||
@@ -63,6 +68,9 @@ $targetReal = realpath($target);
|
||||
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/retool'))) {
|
||||
$skipLayout = true;
|
||||
}
|
||||
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/api'))) {
|
||||
$skipLayout = true;
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
// 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
195
public/page/datenschutz.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$app = app();
|
||||
$config = $app->config();
|
||||
$sessionCookie = $config->cookiePrefix() . 'session';
|
||||
$clientCookie = $config->cookiePrefix() . 'client';
|
||||
?>
|
||||
<main class="container section legal-page">
|
||||
<div class="content-card content-card--narrow page-copy">
|
||||
<h1>Datenschutz & Cookies</h1>
|
||||
<p class="muted small">Stand: 22. Juli 2026</p>
|
||||
|
||||
<p>
|
||||
Diese Hinweise erklären, welche personenbezogenen Daten beim Besuch und bei der Nutzung von Papa-Kind-Treff
|
||||
verarbeitet werden, wofür dies geschieht und welche Auswahlmöglichkeiten du bei Cookies, Analyse und externen Diensten hast.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Verantwortlicher</h2>
|
||||
<p>
|
||||
Lars Gebhardt-Kusche<br>
|
||||
Hammerstraße 47B<br>
|
||||
14167 Berlin
|
||||
</p>
|
||||
<p>
|
||||
E-Mail: <a href="mailto:rechtliches@papa-kind-treff.info">rechtliches@papa-kind-treff.info</a><br>
|
||||
Telefon: +49 (171) 33 10 538
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Aufruf der Website</h2>
|
||||
<p>
|
||||
Beim rein informatorischen Besuch der Website verarbeitet der Server technisch notwendige Verbindungsdaten,
|
||||
zum Beispiel aufgerufene Adresse, Datum und Uhrzeit, übertragene Datenmenge, Browser-Informationen,
|
||||
Referrer und IP-Adresse. Diese Verarbeitung ist erforderlich, um die Website auszuliefern,
|
||||
Stabilität und Sicherheit sicherzustellen und Missbrauch abzuwehren.
|
||||
</p>
|
||||
<p>
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Soweit auf Informationen in deinem Endgerät zugegriffen wird,
|
||||
richtet sich dies zusätzlich nach § 25 TDDDG.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Registrierung, Login und Mitgliederbereich</h2>
|
||||
<p>
|
||||
Wenn du ein Konto anlegst oder den Mitgliederbereich nutzt, verarbeiten wir die dafür notwendigen Daten,
|
||||
insbesondere E-Mail-Adresse, Passwort-Hash, Verifikationsstatus sowie die von dir gepflegten Profilangaben.
|
||||
Dazu können Anzeigename, Name, Ort, Sprachen, Kurzbeschreibung, optionale Kinderangaben,
|
||||
eigene Events, Event-Teilnahmen und Community-Inhalte gehören.
|
||||
</p>
|
||||
<p>
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, soweit die Verarbeitung für die Durchführung des
|
||||
Nutzungsverhältnisses erforderlich ist.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Community, Beiträge und Events</h2>
|
||||
<p>
|
||||
Wenn du Themen, Antworten oder Termine veröffentlichst, werden diese Inhalte mit deinem Konto verknüpft und
|
||||
entsprechend der jeweiligen Sichtbarkeit innerhalb der Plattform angezeigt. Moderationsvorgänge wie Meldungen,
|
||||
Rollen, Bewerbungen oder Community-Sperren werden verarbeitet, soweit dies für den Betrieb einer sicheren und
|
||||
funktionsfähigen Community erforderlich ist.
|
||||
</p>
|
||||
<p>
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO sowie ergänzend Art. 6 Abs. 1 lit. f DSGVO
|
||||
für den sicheren und geordneten Betrieb der Plattform.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Verschlüsselte und sensible Angaben</h2>
|
||||
<p>
|
||||
Bestimmte Angaben, insbesondere Telefoninformationen und einzelne Kinderdaten, werden nicht nur organisatorisch,
|
||||
sondern zusätzlich verschlüsselt verarbeitet. Das dient dem Schutz besonders sensibler Angaben innerhalb der Plattform.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Cookies, lokale Speicherung und Sitzungsdaten</h2>
|
||||
<p>
|
||||
Die Website verwendet technisch notwendige Cookies und, je nach deiner Auswahl, weitere lokale Speichermechanismen
|
||||
wie `localStorage` und `sessionStorage`.
|
||||
</p>
|
||||
<p>
|
||||
Aktuell werden insbesondere folgende technisch relevanten Einträge verwendet:
|
||||
</p>
|
||||
<ul class="list">
|
||||
<li>`<?= htmlspecialchars($sessionCookie, ENT_QUOTES) ?>`: Sitzungs-Cookie für Login und geschützte Bereiche</li>
|
||||
<li>`<?= htmlspecialchars($clientCookie, ENT_QUOTES) ?>`: technische Client-Kennung zur Wiedererkennung des Browsers</li>
|
||||
<li>`pkt_cookie_consent`: Speichert deine Auswahl zu Analyse und externen Diensten</li>
|
||||
<li>`pkt_user_location`, `pkt_user_location_session`, `pkt_user_location_session_preference`, `pkt_location_preference_guest`: nur bei Nutzung standortbezogener Funktionen und abhängig von Einwilligung bzw. Auswahl</li>
|
||||
</ul>
|
||||
<p>
|
||||
Technisch notwendige Cookies und Speicherungen werden auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO
|
||||
beziehungsweise Art. 6 Abs. 1 lit. b DSGVO genutzt sowie im Rahmen von § 25 Abs. 2 TDDDG,
|
||||
soweit sie für die Bereitstellung des ausdrücklich gewünschten Dienstes erforderlich sind.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. Einwilligungsverwaltung</h2>
|
||||
<p>
|
||||
Für nicht notwendige Funktionen verwenden wir eine eigene Einwilligungsverwaltung. Dort kannst du auswählen,
|
||||
ob Analyse und externe Dienste aktiviert werden dürfen. Deine Auswahl kann jederzeit über den Link
|
||||
„Cookie-Einstellungen“ im Footer geändert werden.
|
||||
</p>
|
||||
<p>
|
||||
Rechtsgrundlage für einwilligungspflichtige Funktionen ist Art. 6 Abs. 1 lit. a DSGVO
|
||||
in Verbindung mit § 25 Abs. 1 TDDDG.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>8. Matomo-Analyse</h2>
|
||||
<p>
|
||||
Sofern du der Kategorie „Analyse“ zustimmst, wird Matomo zur Reichweitenmessung und Nutzungsanalyse eingebunden.
|
||||
Dabei können Nutzungsdaten und technisch erforderliche Analyse-Cookies verarbeitet werden.
|
||||
</p>
|
||||
<p>
|
||||
Matomo wird erst nach deiner Einwilligung geladen. Wenn du die Einwilligung widerrufst,
|
||||
wird die Analyse für künftige Besuche beendet.
|
||||
</p>
|
||||
<p>
|
||||
Empfänger in diesem Zusammenhang ist die Matomo-Instanz unter `matomo.my-statistics.info`.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>9. Standort, Karten und externe Dienste</h2>
|
||||
<p>
|
||||
Wenn du standortbezogene Funktionen nutzt oder freigibst, kann der Browser deinen aktuellen Standort abfragen.
|
||||
Je nach Auswahl wird dieser nur für den aktuellen Besuch oder darüber hinaus lokal gespeichert,
|
||||
damit dir passende Treffen in deiner Nähe angezeigt werden können.
|
||||
</p>
|
||||
<p>
|
||||
Für Karten- und Adressfunktionen werden derzeit externe Dienste genutzt:
|
||||
</p>
|
||||
<ul class="list">
|
||||
<li>`unpkg.com` für die Bereitstellung von Leaflet-Dateien</li>
|
||||
<li>OpenStreetMap Nominatim für Geocoding und Reverse-Geocoding</li>
|
||||
</ul>
|
||||
<p>
|
||||
Diese Funktionen werden erst aktiv, wenn du der Kategorie „Externe Dienste, Karten und Standort“ zugestimmt hast.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>10. Empfänger und Kategorien von Empfängern</h2>
|
||||
<p>
|
||||
Eine Weitergabe erfolgt nur, soweit sie für den Betrieb der Website oder einzelner Funktionen erforderlich ist.
|
||||
Dazu können insbesondere technische Hosting-Dienstleister, E-Mail-Dienstleister,
|
||||
Analyse-Dienstleister sowie die oben genannten externen Karten- und Standortdienste gehören.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>11. Speicherdauer</h2>
|
||||
<p>
|
||||
Wir speichern personenbezogene Daten nur so lange, wie dies für die jeweiligen Zwecke erforderlich ist
|
||||
oder gesetzliche Aufbewahrungspflichten bestehen. Kontodaten, Profilinformationen, Community-Inhalte
|
||||
und Eventdaten bleiben grundsätzlich gespeichert, solange das Konto genutzt wird oder bis eine Löschung
|
||||
beantragt beziehungsweise technisch vorgesehen ist, soweit keine gesetzlichen oder berechtigten Gründe
|
||||
für eine längere Aufbewahrung bestehen.
|
||||
</p>
|
||||
<p>
|
||||
Lokale Einwilligungs- und Standortinformationen werden abhängig von ihrer Funktion entweder sitzungsbezogen
|
||||
oder bis zu einer Änderung oder Löschung im Browser gespeichert.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>12. Deine Rechte</h2>
|
||||
<p>
|
||||
Du hast nach Maßgabe der gesetzlichen Voraussetzungen das Recht auf Auskunft, Berichtigung, Löschung,
|
||||
Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch.
|
||||
Eine erteilte Einwilligung kannst du jederzeit mit Wirkung für die Zukunft widerrufen.
|
||||
</p>
|
||||
<p>
|
||||
Außerdem hast du das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>13. Hinweis zur Aktualisierung</h2>
|
||||
<p>
|
||||
Diese Hinweise werden angepasst, wenn sich Funktionen, eingesetzte Dienste oder rechtliche Anforderungen ändern.
|
||||
Insbesondere bei neuen Cookies, Tracking-Technologien oder Drittanbietern wird diese Seite ergänzt.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
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,
|
||||
lat 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_email VARBINARY(512) NULL,
|
||||
profession VARBINARY(512) NULL,
|
||||
@@ -99,15 +100,37 @@ CREATE TABLE event_participants (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 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 (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
board_id BIGINT UNSIGNED NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT 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,
|
||||
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;
|
||||
|
||||
CREATE TABLE forum_posts (
|
||||
@@ -115,13 +138,83 @@ CREATE TABLE forum_posts (
|
||||
thread_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
highlighted_by BIGINT UNSIGNED NULL,
|
||||
highlighted_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT 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_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)
|
||||
) 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)
|
||||
CREATE TABLE user_points (
|
||||
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 = '';
|
||||
$crypto = null;
|
||||
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') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
@@ -163,7 +169,11 @@ final class AccountPages
|
||||
$languages = implode(', ', array_map('trim', $languages));
|
||||
}
|
||||
$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([
|
||||
'name' => trim((string)$_POST['display_name']),
|
||||
'fname' => trim((string)$_POST['first_name']),
|
||||
@@ -174,6 +184,7 @@ final class AccountPages
|
||||
'langs' => trim((string)$languages),
|
||||
'about' => trim((string)$_POST['about']),
|
||||
'phone' => $phoneEnc,
|
||||
'locationPref' => in_array($locationPreference, ['disabled', 'prompt', 'enabled'], true) ? $locationPreference : 'prompt',
|
||||
'id' => $userId,
|
||||
]);
|
||||
$info = 'Profil gespeichert.';
|
||||
@@ -282,6 +293,22 @@ final class AccountPages
|
||||
'id' => $eventId,
|
||||
]);
|
||||
$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) {
|
||||
$error = $e->getMessage();
|
||||
@@ -300,8 +327,12 @@ final class AccountPages
|
||||
'about' => '',
|
||||
'email' => '',
|
||||
'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]);
|
||||
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
||||
if ($row) {
|
||||
@@ -353,7 +384,37 @@ final class AccountPages
|
||||
$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
|
||||
|
||||
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
|
||||
{
|
||||
private ?array $forumStructure = null;
|
||||
private array $tableCache = [];
|
||||
private array $columnCache = [];
|
||||
|
||||
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
|
||||
{
|
||||
$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->execute([
|
||||
':uid' => $userId,
|
||||
':title' => trim($title),
|
||||
':body' => trim($body),
|
||||
':title' => $title,
|
||||
':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 = [];
|
||||
$params = [];
|
||||
@@ -43,17 +140,44 @@ final class Community
|
||||
$params[$ph2] = '%' . $tok . '%';
|
||||
$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)) : '';
|
||||
|
||||
$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,
|
||||
p.display_name,
|
||||
$boardSelect,
|
||||
(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_threads ft2 WHERE ft2.user_id = u.id) AS user_posts
|
||||
FROM forum_threads ft
|
||||
JOIN users u ON u.id = ft.user_id
|
||||
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||
$boardJoin
|
||||
WHERE 1=1 $where
|
||||
ORDER BY ft.created_at DESC
|
||||
LIMIT :lim";
|
||||
@@ -63,29 +187,129 @@ final class Community
|
||||
}
|
||||
$stmt->bindValue(':lim', $limit, \PDO::PARAM_INT);
|
||||
$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
|
||||
{
|
||||
$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]);
|
||||
$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
|
||||
{
|
||||
$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]);
|
||||
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
|
||||
{
|
||||
// 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' => ''];
|
||||
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);
|
||||
}
|
||||
}
|
||||