Compare commits

24 Commits

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

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

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

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

115
.projectstructure.txt Normal file
View 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
View File

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

55
PROJECT_CONTEXT.md Normal file
View 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
View File

@@ -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
View File

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

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

119
config/community_forums.php Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

@@ -29,7 +29,62 @@ $allowNoKidsChecked = $editEvent ? ((int)$editEvent['allow_kids'] === 0) : false
</div>
<div 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
View File

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

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

View File

@@ -5,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>

View File

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

View File

@@ -5,22 +5,89 @@ $app = app();
$pdo = $app->pdo();
$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
View 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 funktionierts</h2>
</div>
</div>
<div class="grid grid-3">
<div class="card step">
<div class="step__icon">1</div>
<h3>Profil anlegen</h3>
<p class="muted small">Papa-Daten ausfüllen, Kinder optional. Sichtbarkeit pro Bereich einstellen.</p>
</div>
<div class="card step">
<div class="step__icon">2</div>
<h3>Events finden</h3>
<p class="muted small">Suche nach Thema, Alter oder Region. Gäste sehen nur Basisinfos.</p>
</div>
<div class="card step">
<div class="step__icon">3</div>
<h3>Treffen planen</h3>
<p class="muted small">Als Mitglied neue Events anlegen, andere einladen, Teilnahme verwalten.</p>
</div>
</div>
</section>
<section class="section alt" id="faq">
<div class="container split">
<div>
<p class="eyebrow">Noch Fragen?</p>
<h2>FAQ</h2>
<div class="faq">
<details open>
<summary>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
View 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
View 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 &amp; 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
View 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
View 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 ?>&amp;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
View 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
View File

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

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

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

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

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

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

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

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

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

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

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

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

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

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

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

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

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

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

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

169
public/assets/css/app.css Normal file → Executable file
View 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
View File

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

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

624
public/assets/js/app.js Normal file → Executable file
View 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
View 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

View File

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

View File

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

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

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

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

195
public/page/datenschutz.php Normal file
View 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 &amp; 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
View File

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

95
schema.sql Normal file → Executable file
View File

@@ -29,6 +29,7 @@ CREATE TABLE user_profiles (
region VARCHAR(120) NULL,
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
View File

67
src/App/AccountPages.php Normal file → Executable file
View 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
View File

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

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

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

@@ -5,17 +5,64 @@ namespace App;
final class Community
{
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
View File

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

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

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