Compare commits

...

105 Commits

Author SHA1 Message Date
774260afaa yxcyxc
All checks were successful
Deploy / deploy (push) Successful in 57s
2026-08-24 00:44:26 +02:00
577b5b742b asdsad
All checks were successful
Deploy / deploy (push) Successful in 57s
2026-08-24 00:27:09 +02:00
49c13dfd80 big
All checks were successful
Deploy / deploy (push) Successful in 1m8s
2026-08-23 02:47:01 +02:00
9bbbd94b98 asd
All checks were successful
Deploy / deploy (push) Successful in 53s
2026-08-21 02:47:56 +02:00
b15bb4cd3a clean up 2
All checks were successful
Deploy / deploy (push) Successful in 55s
2026-08-21 02:39:16 +02:00
c8ed8fab3b Clean up
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-08-21 02:20:46 +02:00
fae2dcc830 Community Orte Veranstalungen
All checks were successful
Deploy / deploy (push) Successful in 57s
2026-08-21 02:02:24 +02:00
a303d9ff70 asdasd
Some checks failed
Deploy / deploy (push) Failing after 14m5s
2026-08-20 20:05:20 +02:00
ca8544b31b yxcyc
All checks were successful
Deploy / deploy (push) Successful in 57s
2026-08-19 20:58:48 +02:00
b0d80e209c kategorie
All checks were successful
Deploy / deploy (push) Successful in 55s
2026-08-19 20:51:09 +02:00
adb41791d9 adasd
All checks were successful
Deploy / deploy (push) Successful in 56s
2026-08-19 20:43:46 +02:00
5401e89a2b ydasd
All checks were successful
Deploy / deploy (push) Successful in 53s
2026-08-19 20:33:08 +02:00
39dfc97ae6 yyxc
All checks were successful
Deploy / deploy (push) Successful in 56s
2026-08-19 20:14:15 +02:00
c567d19deb sadasd
All checks were successful
Deploy / deploy (push) Successful in 56s
2026-08-19 20:04:01 +02:00
c2eaa5c186 asdasd
All checks were successful
Deploy / deploy (push) Successful in 53s
2026-08-17 00:49:49 +02:00
f7a6b34a97 sadsd
All checks were successful
Deploy / deploy (push) Successful in 53s
2026-08-17 00:39:46 +02:00
0f57f90568 sdasd
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-08-17 00:34:41 +02:00
0d384cee2d dsadsa
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-08-17 00:24:57 +02:00
9d5bad8294 adasd
All checks were successful
Deploy / deploy (push) Successful in 58s
2026-08-17 00:05:18 +02:00
d3ebe6fa68 adsad
All checks were successful
Deploy / deploy (push) Successful in 57s
2026-08-11 22:23:21 +02:00
016cf17971 dsad
All checks were successful
Deploy / deploy (push) Successful in 55s
2026-08-11 21:58:47 +02:00
6ca7d6d240 adasd
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-08-11 21:48:08 +02:00
cfaf26ac68 event
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-08-11 21:39:01 +02:00
f3e0f28424 event
All checks were successful
Deploy / deploy (push) Successful in 55s
2026-08-11 21:36:02 +02:00
a5b23de977 update event
All checks were successful
Deploy / deploy (push) Successful in 53s
2026-08-11 21:30:37 +02:00
f14e060f4a Update Events
All checks were successful
Deploy / deploy (push) Successful in 55s
2026-08-11 21:23:58 +02:00
5d979e60bf yasdasd
All checks were successful
Deploy / deploy (push) Successful in 56s
2026-08-10 22:57:21 +02:00
018496f720 sadasd
All checks were successful
Deploy / deploy (push) Successful in 53s
2026-08-10 21:56:30 +02:00
b96e4c8448 eents
All checks were successful
Deploy / deploy (push) Successful in 53s
2026-08-10 21:45:28 +02:00
7b8e067829 events
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-08-10 21:36:05 +02:00
b12bde2476 asdas
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-08-10 21:14:07 +02:00
e0cc5989cc change event
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-08-10 21:00:54 +02:00
d442b888a5 dashboard
All checks were successful
Deploy / deploy (push) Successful in 53s
2026-08-10 20:32:01 +02:00
5c0ecf817a sadasd
All checks were successful
Deploy / deploy (push) Successful in 53s
2026-08-10 20:28:45 +02:00
bd758f8b34 clean docu
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-08-10 20:11:03 +02:00
49b248a39f dasdsad
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-08-08 00:18:20 +02:00
ddac1c182b ycys
All checks were successful
Deploy / deploy (push) Successful in 53s
2026-08-07 23:59:12 +02:00
a102b2aa0c last change
All checks were successful
Deploy / deploy (push) Successful in 56s
2026-08-07 23:43:36 +02:00
c9b1839460 xddf
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-08-06 01:23:38 +02:00
df76199269 ycdf
All checks were successful
Deploy / deploy (push) Successful in 52s
2026-08-06 01:20:04 +02:00
fa40ca1a60 XyX
All checks were successful
Deploy / deploy (push) Successful in 53s
2026-08-06 01:17:05 +02:00
80f1ad6b99 addsad
All checks were successful
Deploy / deploy (push) Successful in 57s
2026-08-06 00:56:03 +02:00
565d197bb2 adsad
All checks were successful
Deploy / deploy (push) Successful in 1m2s
2026-08-04 22:07:29 +02:00
ac32727dc6 Eventupdate
All checks were successful
Deploy / deploy (push) Successful in 1m0s
2026-08-04 22:00:43 +02:00
9cfa26a031 rebuild events
All checks were successful
Deploy / deploy (push) Successful in 59s
2026-08-04 20:59:40 +02:00
1a58ec91de yxcyxc
All checks were successful
Deploy / deploy (push) Successful in 55s
2026-08-04 01:20:59 +02:00
fa6e975df4 ycxyxc
All checks were successful
Deploy / deploy (push) Successful in 1m3s
2026-08-04 00:42:40 +02:00
fc34e87622 yxcyxc
All checks were successful
Deploy / deploy (push) Successful in 1m3s
2026-08-03 23:27:35 +02:00
faf614010d sadasd
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-08-03 22:22:09 +02:00
258a3a2229 asdasd
All checks were successful
Deploy / deploy (push) Successful in 56s
2026-08-03 22:10:34 +02:00
84443b6cca adsd
All checks were successful
Deploy / deploy (push) Successful in 58s
2026-08-03 21:59:49 +02:00
ded7f98255 events
All checks were successful
Deploy / deploy (push) Successful in 52s
2026-08-03 21:51:32 +02:00
0197ad93d8 search and seo
All checks were successful
Deploy / deploy (push) Successful in 1m0s
2026-08-03 21:46:39 +02:00
b47e2961ae yxcyxc
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-08-03 21:39:32 +02:00
10557acb0e asdasd
All checks were successful
Deploy / deploy (push) Successful in 1m30s
2026-08-03 21:35:28 +02:00
974f4880d0 commi
All checks were successful
Deploy / deploy (push) Successful in 1m1s
2026-07-31 22:41:23 +02:00
25a50ae0da csds
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-07-31 22:12:18 +02:00
74c52a0705 XYX
All checks were successful
Deploy / deploy (push) Successful in 58s
2026-07-31 21:58:32 +02:00
c335fd6f5c csadas
All checks were successful
Deploy / deploy (push) Successful in 58s
2026-07-31 21:45:19 +02:00
002108ac83 dsfdsf
All checks were successful
Deploy / deploy (push) Successful in 59s
2026-07-31 21:33:04 +02:00
92442a57c7 Dastenschutz
All checks were successful
Deploy / deploy (push) Successful in 53s
2026-07-31 21:10:17 +02:00
35b3b65e22 adresse
All checks were successful
Deploy / deploy (push) Successful in 1m3s
2026-07-31 20:49:24 +02:00
db9b02449d csad
All checks were successful
Deploy / deploy (push) Successful in 58s
2026-07-31 20:40:25 +02:00
f6cb211859 adasd
All checks were successful
Deploy / deploy (push) Successful in 58s
2026-07-30 00:04:51 +02:00
76591569b6 asdasd
All checks were successful
Deploy / deploy (push) Successful in 58s
2026-07-29 22:54:50 +02:00
d26f4a5792 adsad
All checks were successful
Deploy / deploy (push) Successful in 1m5s
2026-07-29 22:25:15 +02:00
caa5b86414 adasd
All checks were successful
Deploy / deploy (push) Successful in 1m5s
2026-07-29 21:46:16 +02:00
f0ff95679b adasd
All checks were successful
Deploy / deploy (push) Successful in 1m1s
2026-07-29 21:27:36 +02:00
ae34348b29 ycydcsd
All checks were successful
Deploy / deploy (push) Successful in 1m0s
2026-07-29 21:09:51 +02:00
37bea188d5 adasd
All checks were successful
Deploy / deploy (push) Successful in 1m5s
2026-07-29 20:57:47 +02:00
0e73147886 Adsasd
All checks were successful
Deploy / deploy (push) Successful in 1m5s
2026-07-28 22:30:32 +02:00
aa86001760 avatar
All checks were successful
Deploy / deploy (push) Successful in 1m7s
2026-07-28 02:03:01 +02:00
0ac69fdfd5 ycyxc 2026-07-28 01:29:31 +02:00
4095d7edbd ycxyc
All checks were successful
Deploy / deploy (push) Successful in 1m8s
2026-07-28 00:50:47 +02:00
d0fc8890cf ycyxc
All checks were successful
Deploy / deploy (push) Successful in 55s
2026-07-28 00:36:14 +02:00
b3e2516c2a Avatar
All checks were successful
Deploy / deploy (push) Successful in 53s
2026-07-28 00:29:41 +02:00
3d7fa4d3a9 yxcyxc
All checks were successful
Deploy / deploy (push) Successful in 50s
2026-07-28 00:17:12 +02:00
6b1ee354c1 ycyxc
All checks were successful
Deploy / deploy (push) Successful in 59s
2026-07-27 23:47:21 +02:00
eed4c13a01 asdd
All checks were successful
Deploy / deploy (push) Successful in 52s
2026-07-27 23:33:27 +02:00
bfae1a4529 Avatar
All checks were successful
Deploy / deploy (push) Successful in 51s
2026-07-27 23:23:58 +02:00
5043b83118 asdasd
All checks were successful
Deploy / deploy (push) Successful in 59s
2026-07-27 23:13:05 +02:00
280251cae0 asdasd
All checks were successful
Deploy / deploy (push) Successful in 56s
2026-07-27 22:49:38 +02:00
932a070652 xvxcv
All checks were successful
Deploy / deploy (push) Successful in 55s
2026-07-27 21:40:40 +02:00
36092027d2 sdasd
All checks were successful
Deploy / deploy (push) Successful in 54s
2026-07-27 21:26:46 +02:00
b98f9ee6eb avatar
All checks were successful
Deploy / deploy (push) Successful in 56s
2026-07-27 21:11:59 +02:00
c7acea5f8b adasd
All checks were successful
Deploy / deploy (push) Successful in 52s
2026-07-27 03:00:45 +02:00
4a2596ba21 adasd
All checks were successful
Deploy / deploy (push) Successful in 52s
2026-07-27 02:49:42 +02:00
f757c4522e avatar
All checks were successful
Deploy / deploy (push) Successful in 51s
2026-07-27 02:38:04 +02:00
f59d50f778 cleanup
All checks were successful
Deploy / deploy (push) Successful in 59s
2026-07-27 02:26:08 +02:00
da942a76aa adsad
All checks were successful
Deploy / deploy (push) Successful in 1m41s
2026-07-27 02:08:28 +02:00
75e3cab14b Dokus
All checks were successful
Deploy / deploy (push) Successful in 1m39s
2026-07-27 02:01:46 +02:00
8b106e5b03 Avatar
All checks were successful
Deploy / deploy (push) Successful in 1m38s
2026-07-27 01:58:53 +02:00
32985d5785 avatar 2
All checks were successful
Deploy / deploy (push) Successful in 1m43s
2026-07-27 01:50:38 +02:00
08fffd24b6 dssad
All checks were successful
Deploy / deploy (push) Successful in 1m43s
2026-07-27 01:41:52 +02:00
708830fe0a avatar generator
All checks were successful
Deploy / deploy (push) Successful in 1m39s
2026-07-27 01:36:24 +02:00
e274e07f45 synch
All checks were successful
Deploy / deploy (push) Successful in 1m43s
2026-07-27 00:24:12 +02:00
56d29f09e5 ysdsad
All checks were successful
Deploy / deploy (push) Successful in 1m48s
2026-07-24 22:07:00 +02:00
69fa2e029d sxsad
All checks were successful
Deploy / deploy (push) Successful in 1m43s
2026-07-24 21:58:59 +02:00
0956a984cb ycyxcx
All checks were successful
Deploy / deploy (push) Successful in 1m13s
2026-07-24 21:43:37 +02:00
9a5f1b1a5d avatar
All checks were successful
Deploy / deploy (push) Successful in 1m5s
2026-07-24 21:28:35 +02:00
5630ff2308 xccxv
All checks were successful
Deploy / deploy (push) Successful in 51s
2026-07-24 21:09:33 +02:00
5256dd4080 ycxc
All checks were successful
Deploy / deploy (push) Successful in 51s
2026-07-24 21:00:47 +02:00
d507ae7bc7 asdasd
All checks were successful
Deploy / deploy (push) Successful in 51s
2026-07-24 20:40:49 +02:00
cbecbef830 adasd
All checks were successful
Deploy / deploy (push) Successful in 52s
2026-07-22 22:19:31 +02:00
2e800d4839 new build
All checks were successful
Deploy / deploy (push) Successful in 51s
2026-07-22 21:40:34 +02:00
59 changed files with 10863 additions and 751 deletions

View File

@@ -95,6 +95,8 @@ jobs:
[ -f "$f" ] && cp "$f" .ci_config_deploy/
done
[ -f "${CONFIG_BASE_DIR}/.htaccess" ] && cp "${CONFIG_BASE_DIR}/.htaccess" .ci_config_deploy/
cp -R ${CONFIG_ENV_DIR}/. .ci_config_deploy/
echo "🔁 config → ${TARGET_PATH}${CONFIG_BASE_DIR}/"
@@ -105,11 +107,11 @@ jobs:
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}/;
mirror -R --delete --exclude secrets.local.php --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"
echo "✅ Deploy abgeschlossen"

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Local server secrets
/config/secrets.local.php
# Local environment files
/.env
/.env.*
!/.env.example

View File

@@ -90,6 +90,7 @@ deploy:staging:
cp "$f" .ci_config_deploy/
fi
done
[ -f "${CONFIG_BASE_DIR}/.htaccess" ] && cp "${CONFIG_BASE_DIR}/.htaccess" .ci_config_deploy/
fi
if [ -d "${CONFIG_ENV_DIR}" ]; then
@@ -104,7 +105,7 @@ deploy:staging:
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}/;
mirror -R --delete --exclude secrets.local.php --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
bye
" || { echo "❌ Upload für gemischtes Config-Verzeichnis fehlgeschlagen."; exit 1; }
@@ -197,6 +198,7 @@ deploy:production:
cp "$f" .ci_config_deploy/
fi
done
[ -f "${CONFIG_BASE_DIR}/.htaccess" ] && cp "${CONFIG_BASE_DIR}/.htaccess" .ci_config_deploy/
fi
if [ -d "${CONFIG_ENV_DIR}" ]; then
@@ -211,7 +213,7 @@ deploy:production:
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}/;
mirror -R --delete --exclude secrets.local.php --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
bye
" || { echo "❌ Upload für gemischtes Config-Verzeichnis fehlgeschlagen."; exit 1; }
@@ -224,4 +226,3 @@ deploy:production:
only:
- main
# when: manual

View File

@@ -1,73 +0,0 @@
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.

24
Internal/README.md Normal file
View File

@@ -0,0 +1,24 @@
# Internal Documentation
Updated: 2026-08-10
Canonical internal project documentation lives in this directory.
## Directory Structure
- `de/README.md`
- `de/PROJECT_CONTEXT.md`
- `de/PROJECT_STRUCTURE.md`
- `en/README.md`
- `en/PROJECT_CONTEXT.md`
- `en/PROJECT_STRUCTURE.md`
- `db/schema.sql`
- `archive/legacy-root-files/`
## Maintenance Rule
- German and English documentation must be maintained as fully parallel references.
- Changes to project behavior, structure, wording, privacy, security, or product decisions must be reflected in both language sets.
- The root `README.md` remains in the project root as the mandatory start document for new chats.
## Scope Rule
- Public-facing website copy does not belong here.
- `Internal/` is only for internal project, product, architecture, and maintenance documentation.

View File

@@ -0,0 +1,119 @@
Papa-Kind-Treff Projektstruktur und Pflegehinweise
Stand: 2026-07-24
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 und Avatar-Merkmale.
- `src/App/Avatar.php`
Avatar-Presets, Asset-Pfade und Rendering im modernen Portrait-Stil.
- `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 Direktlinks zu `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen`, `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.
- Mitgliederbereich als Bereichslayout mit linker Navigation und separaten Seiten für Profil, Kinder, Termine, Community und Einstellungen.
- Profilbereich mit moderner Avatar-Preset-Auswahl auf Basis echter Portrait-Assets.
- 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 Einstellungsbereich:
- `disabled`
- `prompt`
- `enabled`
- Im Einstellungsbereich stehen zusätzlich Browser-Hinweise zur Standortfreigabe sowie der Einstieg in die Consent-Verwaltung.
- 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.

View File

@@ -0,0 +1,8 @@
Projektstruktur Verweis
Die kanonische interne Strukturdokumentation wurde verschoben nach:
- `Internal/de/PROJECT_STRUCTURE.md`
- `Internal/en/PROJECT_STRUCTURE.md`
Bitte diese Dateien als Referenz verwenden und bei Änderungen dort pflegen.

View File

@@ -0,0 +1,8 @@
# Projektkontext Verweis
Die kanonische interne Kontextdokumentation wurde verschoben nach:
- `Internal/de/PROJECT_CONTEXT.md`
- `Internal/en/PROJECT_CONTEXT.md`
Bitte diese Dateien als Referenz verwenden und bei Änderungen dort pflegen.

View File

@@ -3,25 +3,30 @@
-- - Passwörter mit Argon2id hashen.
-- - Sensible Felder werden app-seitig mit libsodium (XChaCha20-Poly1305) verschlüsselt
-- und als base64 in VARBINARY-Spalten abgelegt (nonce + cipher).
-- - Konto-E-Mails werden app-seitig verschlüsselt gespeichert; für die technische Suche
-- und Dublettenprüfung wird zusätzlich ein HMAC-SHA-256-Lookup-Hash abgelegt.
-- - share_level steuert Papa-Infos (basic, papa, papa_contact).
-- - children_visibility steuert Kinder-Infos separat (hidden, age_only, details).
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
email VARBINARY(512) NOT NULL,
email_lookup_hash CHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
status ENUM('active','pending','blocked') DEFAULT 'pending',
email_verified_at DATETIME NULL,
last_login_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_users_email_lookup_hash (email_lookup_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE user_profiles (
user_id BIGINT UNSIGNED PRIMARY KEY,
display_name VARCHAR(120) NOT NULL,
first_name VARCHAR(120) NULL,
last_name VARCHAR(120) NULL,
first_name VARBINARY(512) NULL,
last_name VARBINARY(512) NULL,
street VARBINARY(512) NULL,
share_level ENUM('basic','papa','papa_contact') NOT NULL DEFAULT 'basic',
children_visibility ENUM('hidden','age_only','details') NOT NULL DEFAULT 'hidden',
zip CHAR(5) NULL,
@@ -30,6 +35,17 @@ CREATE TABLE user_profiles (
lat DECIMAL(10,7) NULL,
lng DECIMAL(10,7) NULL,
location_tracking_preference ENUM('disabled','prompt','enabled') NOT NULL DEFAULT 'prompt',
avatar_style VARCHAR(40) NOT NULL DEFAULT 'lorelei',
avatar_seed VARCHAR(100) NOT NULL DEFAULT '',
avatar_config_json TEXT NULL,
avatar_preset VARCHAR(64) NOT NULL DEFAULT 'papa-kind-treff',
avatar_lorelei_eyes_variant VARCHAR(24) NOT NULL DEFAULT '',
avatar_lorelei_eyebrows_variant VARCHAR(24) NOT NULL DEFAULT '',
avatar_lorelei_mouth_variant VARCHAR(24) NOT NULL DEFAULT '',
avatar_lorelei_glasses_variant VARCHAR(24) NOT NULL DEFAULT '',
avatar_lorelei_hair_variant VARCHAR(24) NOT NULL DEFAULT '',
avatar_lorelei_beard_variant VARCHAR(24) NOT NULL DEFAULT '',
avatar_lorelei_earrings_variant VARCHAR(24) NOT NULL DEFAULT '',
contact_phone VARBINARY(512) NULL,
contact_email VARBINARY(512) NULL,
profession VARBINARY(512) NULL,
@@ -63,7 +79,11 @@ CREATE TABLE events (
title VARCHAR(200) NOT NULL,
teaser_public VARCHAR(280) NOT NULL,
description TEXT NOT NULL,
category_slug VARCHAR(120) NULL,
image_path VARCHAR(255) NULL,
location_label VARCHAR(180) NULL,
location_source_type ENUM('custom','place','editorial_event') NOT NULL DEFAULT 'custom',
location_source_listing_id BIGINT UNSIGNED NULL,
street VARCHAR(180) NULL,
zip CHAR(5) NULL,
city VARCHAR(120) NULL,
@@ -99,6 +119,169 @@ CREATE TABLE event_participants (
CONSTRAINT fk_ep_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE system_settings (
`key` VARCHAR(120) NOT NULL PRIMARY KEY,
`value` TEXT NULL,
updated_by BIGINT UNSIGNED NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_system_settings_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE listing_categories (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(160) NOT NULL,
category_group ENUM('general','event','place','food','family','partner') NOT NULL DEFAULT 'general',
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE listing_places (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
created_by BIGINT UNSIGNED NULL,
source_type ENUM('user','partner','admin','system') NOT NULL DEFAULT 'user',
title VARCHAR(180) NOT NULL,
description TEXT NULL,
street VARCHAR(180) NULL,
zip CHAR(5) NULL,
city VARCHAR(120) NULL,
region VARCHAR(120) NULL,
lat DECIMAL(10,7) NULL,
lng DECIMAL(10,7) NULL,
website_url VARCHAR(255) NULL,
phone VARCHAR(60) NULL,
place_kind VARCHAR(80) NULL,
opening_hours_note TEXT NULL,
opening_hours_json LONGTEXT NULL,
provider_hint ENUM('manual','osm','google','azure') NOT NULL DEFAULT 'manual',
external_place_id VARCHAR(190) NULL,
google_place_id VARCHAR(190) NULL,
provider_links_json LONGTEXT NULL,
rating_value DECIMAL(3,2) NULL,
rating_count INT UNSIGNED NULL,
status ENUM('draft','published','archived') NOT NULL DEFAULT 'published',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_places_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_listing_places_city (city),
INDEX idx_listing_places_region (region),
INDEX idx_listing_places_latlng (lat,lng),
INDEX idx_listing_places_kind (place_kind)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE listings (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
created_by BIGINT UNSIGNED NULL,
owner_type ENUM('user','partner','admin','system') NOT NULL DEFAULT 'user',
listing_type ENUM('event','partner_offer','place','editorial_event') NOT NULL DEFAULT 'event',
primary_place_id BIGINT UNSIGNED NULL,
title VARCHAR(200) NOT NULL,
teaser_public VARCHAR(280) NOT NULL,
description TEXT NOT NULL,
image_path VARCHAR(255) NULL,
special_conditions_note TEXT NULL,
visibility ENUM('public','members') NOT NULL DEFAULT 'public',
status ENUM('draft','published','cancelled','archived') NOT NULL DEFAULT 'draft',
supports_registration TINYINT(1) NOT NULL DEFAULT 0,
supports_capacity TINYINT(1) NOT NULL DEFAULT 0,
supports_pricing TINYINT(1) NOT NULL DEFAULT 0,
is_recurring TINYINT(1) NOT NULL DEFAULT 0,
legacy_event_id BIGINT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listings_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_listings_place FOREIGN KEY (primary_place_id) REFERENCES listing_places(id) ON DELETE SET NULL,
CONSTRAINT fk_listings_legacy_event FOREIGN KEY (legacy_event_id) REFERENCES events(id) ON DELETE SET NULL,
INDEX idx_listings_type (listing_type),
INDEX idx_listings_status (status),
INDEX idx_listings_owner (owner_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE listing_category_map (
listing_id BIGINT UNSIGNED NOT NULL,
category_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (listing_id, category_id),
CONSTRAINT fk_lcm_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
CONSTRAINT fk_lcm_category FOREIGN KEY (category_id) REFERENCES listing_categories(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE listing_occurrences (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
listing_id BIGINT UNSIGNED NOT NULL,
occurrence_type ENUM('single','series','range','open_ended') NOT NULL DEFAULT 'single',
starts_at DATETIME NULL,
ends_at DATETIME NULL,
recurrence_rule VARCHAR(255) NULL,
recurrence_until DATETIME NULL,
capacity_total SMALLINT UNSIGNED NULL,
booking_url VARCHAR(255) NULL,
status ENUM('scheduled','cancelled','sold_out') NOT NULL DEFAULT 'scheduled',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_occurrences_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
INDEX idx_listing_occurrences_start (starts_at),
INDEX idx_listing_occurrences_type (occurrence_type),
INDEX idx_listing_occurrences_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE listing_prices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
listing_id BIGINT UNSIGNED NOT NULL,
occurrence_id BIGINT UNSIGNED NULL,
label VARCHAR(120) NOT NULL,
audience ENUM('general','adult','child','family','group') NOT NULL DEFAULT 'general',
price_type ENUM('free','fixed','from','up_to','range','request') NOT NULL DEFAULT 'fixed',
amount DECIMAL(10,2) NULL,
amount_secondary DECIMAL(10,2) NULL,
currency CHAR(3) NOT NULL DEFAULT 'EUR',
note VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_prices_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
CONSTRAINT fk_listing_prices_occurrence FOREIGN KEY (occurrence_id) REFERENCES listing_occurrences(id) ON DELETE CASCADE,
INDEX idx_listing_prices_listing (listing_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE listing_benefits (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
listing_id BIGINT UNSIGNED NOT NULL,
occurrence_id BIGINT UNSIGNED NULL,
benefit_type ENUM('voucher_code','voucher_hint','discount_text') NOT NULL DEFAULT 'voucher_hint',
title VARCHAR(160) NOT NULL,
code VARCHAR(120) NULL,
description TEXT NULL,
valid_from DATETIME NULL,
valid_until DATETIME NULL,
is_public TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_benefits_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
CONSTRAINT fk_listing_benefits_occurrence FOREIGN KEY (occurrence_id) REFERENCES listing_occurrences(id) ON DELETE CASCADE,
INDEX idx_listing_benefits_listing (listing_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE listing_moderation_requests (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
listing_id BIGINT UNSIGNED NOT NULL,
request_type ENUM('create','update','delete') NOT NULL,
request_status ENUM('open','approved','rejected') NOT NULL DEFAULT 'open',
requested_by BIGINT UNSIGNED NOT NULL,
reviewed_by BIGINT UNSIGNED NULL,
request_reason TEXT NULL,
review_note TEXT NULL,
payload_json LONGTEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
reviewed_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_moderation_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
CONSTRAINT fk_listing_moderation_requested_by FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_listing_moderation_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_listing_moderation_status (request_status),
INDEX idx_listing_moderation_type (request_type),
INDEX idx_listing_moderation_user (requested_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Community / Forum
CREATE TABLE forum_categories (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
@@ -265,6 +448,17 @@ CREATE TABLE user_tokens (
INDEX idx_ut_type (type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE user_calendar_feeds (
user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
token_encrypted TEXT NOT NULL,
token_lookup_hash CHAR(64) NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
rotated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_accessed_at DATETIME NULL,
CONSTRAINT fk_user_calendar_feed_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_calendar_feeds_lookup (token_lookup_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Audit-Log für wichtige Aktionen
CREATE TABLE audit_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

View File

@@ -0,0 +1,93 @@
# Papa-Kind-Treff Projektkontext
Stand: 2026-08-07
## Kurzbeschreibung
Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind lokale Events, Termine und Treffen, ein Community-Forum, Mitgliederprofile mit optionalen Kinderinfos und ein Moderations-/Adminbereich.
## Wichtige Produktentscheidungen
- Die Hauptnavigation ist bewusst knapp gehalten: `Home`, `Event Suche`, `Community`.
- `Events` ist der Primärbegriff im Produkt.
- `Termine` und `Treffen` werden ergänzend in SEO- und Erklärungstexten genutzt.
- Die Community ist hierarchisch aufgebaut: Kategorien -> Boards -> Threads -> Posts.
- Moderation ist vom normalen Community-Frontend getrennt.
- Standortfunktionen sind relevant für lokale Events, Termine und Treffen.
- Die bestehende `events`-Logik bleibt zunächst aktiv, wird aber künftig von einer allgemeineren Listing-/Ort-/Termin-Struktur abgelöst.
- Seiten-Admins erhalten eine eigene System-Sektion für globale Betriebs- und Diensteschalter.
## Konto / Mitgliederbereich
- Das Profilmenü oben rechts enthält nur `Profil`, für berechtigte Nutzer zusätzlich `Admin-Einstellungen`, sowie `Abmelden`.
- Der Profilbereich enthält persönliche Angaben, Kinder, eigene Events, eigene Orte und Veranstaltungen, Cookie-/Kontoeinstellungen sowie Community-Informationen.
- Der Admin-Bereich ist getrennt vom Profilbereich und enthält abhängig von der Berechtigung Profil-Levels, Kategorien, System-Einstellungen sowie Community-Moderation und Listing-Freigaben.
- Die Admin-Unterbereiche bleiben im selben Dashboard-Layout. Listing-Freigaben zeigen ausschließlich noch nicht veröffentlichte Orte und Veranstaltungen inklusive direkter Bearbeitung; Community-Moderation zeigt nur offene Bewerbungen und Meldungen; Rollen und System-Levels liegen im User Management, die Community-Migration liegt unter System.
- Bestehende Direktlinks zu `Kategorien`, `Profil-Levels` und `System` werden automatisch in den Admin-Bereich eingeordnet.
- Kinder können angelegt, bearbeitet und gelöscht werden.
- Wenn ein Geburtsdatum gesetzt ist, wird das Alter automatisch berechnet und später bei Bedarf aktualisiert.
- Die Konto-E-Mail wird app-seitig verschlüsselt gespeichert und über einen separaten HMAC-Lookup-Hash adressiert.
- Profiladresse kann per Suche oder Browser-Standort übernommen werden.
- Der Bereich `Events` ist wieder auf echte eigene Events und Event-Teilnahmen begrenzt.
- Der Bereich `Events` bietet zusätzlich einen manuellen ICS-Export und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen.
- Der Bereich `Events` ist im UI in `Meine Events`, `Kalendersynchronisation` und `Abgelaufene Events` getrennt, damit aktive Events, Kalender-Themen und Vergangenes sauber getrennt bleiben.
- In `Meine Events` sitzt der Button `Neues Event anlegen` jetzt direkt in der oberen Tab-Zeile; die bisherige Teilnahme-Box heißt `Angemeldete Events` und enthält zusätzlich einen direkten Link zur `Event Suche`.
- Orte und Veranstaltungen sind vorerst in einen separaten Mitgliederbereichspunkt `Orte & Veranstaltungen` verschoben.
- In `Orte & Veranstaltungen` können Nutzer dauerhafte Orte und zeitlich begrenzte Veranstaltungen neu anlegen.
- Neue Orte und Veranstaltungen bleiben bis zur Freigabe im Status `wartet auf Freigabe` und werden erst danach systemweit veröffentlicht.
- Noch nicht veröffentlichte Orts- und Veranstaltungsvorschläge können vom Ersteller im Mitgliederbereich direkt weiterbearbeitet werden.
- Änderungs- und Löschwünsche für veröffentlichte Orte und Veranstaltungen laufen immer über eine begründete Moderationsanfrage.
- Vorschläge für neue Orte und Veranstaltungen dürfen schon vor der Freigabe als Ortsvorschlag für Events genutzt werden, solange sie noch offen geprüft werden.
- Dubletten bei Orten und Veranstaltungen sollen serverseitig mindestens über Namen und Adresse abgefangen werden.
- Die Eingabe im Bereich `Events` soll so einfach wie möglich bleiben und zeigt deshalb nur die für echte Termine relevanten Felder.
- Eigene Events nutzen jetzt als Pflichtfelder nur noch Titel, Datum und `Mit Kindern`; die Beschreibung ist optional.
- Für die Event-Location gibt es jetzt die Auswahl `Ort aus Datenbank`, `Veranstaltung aus Datenbank` oder `Benutzerdefiniert`.
- Bei `Ort` oder `Veranstaltung` werden Adresse und Kategorie direkt aus dem gewählten Datenbank-Eintrag übernommen.
- Die Auswahl für Datenbank-Locations läuft im Event-Formular jetzt über ein echtes Auswahlfeld statt über Freitext; `Ort aus Datenbank` zeigt nur Orte, `Veranstaltung aus Datenbank` nur aktuell laufende Veranstaltungen im gültigen Zeitraum.
- Im Formular für eigene Events sind `Gültig bis` und `Wiederholung` ausgeblendet.
- Bei eigener Event-Location aus der Datenbank werden Adresse und Kategorie nur als Info angezeigt und nicht direkt bearbeitet.
- Aus dem Formular für eigene Events kann direkt in das Anlegen eines neuen Orts oder einer neuen Veranstaltung gewechselt werden.
- Nach diesem Zwischenschritt kehrt der Nutzer wieder in sein Event zurück; die bisherige Event-Eingabe bleibt erhalten und der neu angelegte Ort bzw. die neue Veranstaltung wird direkt ausgewählt.
- Bei `Veranstaltung aus Datenbank` muss das Event-Datum innerhalb des hinterlegten Gültigkeitszeitraums dieser Veranstaltungs-Quelle liegen.
- Kategorien werden im Mitgliederbereich per Sucheingabe mit bestehenden Vorschlägen und automatischer Neuanlage gepflegt.
- Eigene Events unterstützen optionalen Beschreibungstext, Ja/Nein-Angabe `Mit Kindern`, optionale Platzzahl, optionales Bild sowie eine Location aus Datenbank oder benutzerdefinierte Adresse.
- `Profil-Levels` ist ein Owner-Bereich für Community-Level und die Übersicht der System-Level.
- Community-Level sind jetzt über `system_settings.community_levels_json` editierbar und enthalten aktuell die Rechte `Hilfreiche Antworten hervorheben` und `Bewerbung als Forum-Admin`.
- In `Profil-Levels` kann der Owner zusätzlich Benutzer suchen und Community-Punkte manuell mit Begründung erhöhen.
- In `Profil-Levels` gibt es zusätzlich eine Liste von Nutzern, die in den letzten 30 Tagen mindestens den Rang `Säule der Väter-Community` erreicht haben.
- System-Level bleiben getrennt als Rollen `Forum-Admin`, `Site-Admin` und `SiteOwner`.
- `Forum-Admin` kann Kategorien bereits bestätigen und zusammenführen sowie Orte und Veranstaltungen freigeben.
- `SiteOwner` und `Site-Admin` erben diese Freigaberechte vollständig mit.
- Die Vergabe von System-Leveln läuft im Bereich `Community-Admin` jetzt über eine Benutzersuche statt über rohe Benutzer-IDs; `Site-Admins` bleiben auf die bestehende Rollenlogik begrenzt.
- Kategorien liegen nicht mehr unter `System`, sondern in einem eigenen Mitgliederbereichspunkt `Kategorien`; dort werden nur neue Kategorien angezeigt und per Vorschlagsfeld mit bestehenden Kategorien aus der Datenbank zusammengeführt.
- Neue Kategorien bleiben dort sichtbar, bis sie von einem Berechtigten bestätigt oder mit einer bestehenden Kategorie zusammengeführt werden.
- Im Bereich `Kategorien` gibt es zusätzlich eine Suche über bestehende Kategorien, damit sie gezielt gefunden und zusammengeführt werden können.
- Beim Zusammenführen wird die nicht zu behaltende Kategorie anschließend vollständig entfernt und die Ansicht danach frisch neu geladen.
- Zusammengeführte Kategorien werden zusätzlich intern als Redirect gesperrt, damit Standard-Seed oder spätere Eingaben sie nicht erneut auswählbar machen.
- Beim Zusammenführen kann explizit festgelegt werden, welche der beiden Kategorien erhalten bleibt.
- Community-Level können zusätzlich die Rechte für Kategorien sowie für die Freigabe von Orten und Veranstaltungen tragen.
- Bestehende gespeicherte Community-Level erhalten diese beiden Rechte im Fallback automatisch ab 750 Punkten, solange sie nicht explizit anders gespeichert wurden.
## Technischer Rahmen
- Externe API-Schlüssel werden ausschließlich über Server-Umgebungsvariablen oder die lokale, nicht versionierte Datei `config/secrets.local.php` außerhalb des Webroots bereitgestellt.
- eigener Front-Controller in `public/index.php`
- Templates unter `partials/`
- Geschäftslogik unter `src/App/`
- Globales Frontend vor allem über `public/assets/js/app.js` und `public/assets/css/app.css`
## Neue Ausbaustufe: Termin-/Ort-System
- Neue Tabellenbasis: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits`, `listing_categories`
- Ziel: spätere Unterscheidung zwischen eigenem Event, Partner-Angebot, allgemeinem Ort und redaktionellem Termin
- In der Admin-Freigabe werden aktivierte Ortsanbieter serverseitig parallel durchsucht: OpenStreetMap/Nominatim, Google Places und Azure Maps. Ein Admin wählt einen Treffer gezielt aus; je Ort können Verknüpfungen mehrerer Anbieter gespeichert werden. Google-Gesamtwertung und Bewertungsanzahl werden erst nach der ausgewählten Google-Verknüpfung geladen.
- Die Admin-Ansicht für Orte und Veranstaltungen hat die Unterpunkte `Freigaben`, `Bestehende Ortsbearbeitung` mit Suchfeld und `Fehlende Angaben`. Letzterer filtert veröffentlichte Einträge nach Verknüpfungen, die bei mindestens einem aktuell aktivierten Anbieter fehlen.
- Nach dem Übernehmen eines externen Treffers wird dessen temporäre Trefferliste entfernt. Der Bearbeiten-Button ist in jeder dieser Unteransichten zur Prüfung von Name und Adresse verfügbar.
- Berechtigte Admins können veröffentlichte Orte und Veranstaltungen direkt korrigieren. Wenn die vollständige OSM-Suchanfrage keinen Treffer liefert, werden zusätzlich vereinfachte Adress- und Name-Ort-Abfragen versucht.
- Eine manuelle Admin-Suche überträgt Name und Adressdaten des offenen Eintrags an die aktivierten Ortsanbieter; Datenschutz- und Consent-Hinweise müssen vor dem produktiven Aktivieren geprüft werden.
## Vorgemerkte To-dos
- Kalender-Integration großer Anbieter soll später zusätzlich zum ICS-Feed kommen, zuerst vor allem für Google und Microsoft/Outlook.
- Zielbild dafür ist keine Rücksynchronisierung, sondern ein nutzerseitiges OAuth-Login über Papa-Kind-Treff und danach eine direkte Kalender-Verknüpfung oder Event-Synchronisierung beim jeweiligen Anbieter.
## Pflegehinweis
Bei Änderungen immer auch die parallelen Dateien in `Internal/en/` prüfen und inhaltlich gleichziehen.
## Dokumentationsprinzip
- `Internal/de/` und `Internal/en/` werden bewusst parallel gepflegt.
- Die Root-`README.md` dient als Start-Doku für neue Chats und muss bei grundlegenden Änderungen ebenfalls mitgeprüft werden.

View File

@@ -0,0 +1,52 @@
# Papa-Kind-Treff Projektstruktur
Stand: 2026-08-10
## Projektordner
- `config/`: Konfigurationen, inklusive `prod/` und `staging/`
- `config/secrets.local.php`: lokale, nicht versionierte Server-Geheimnisse außerhalb des Webroots; Vorlage: `config/secrets.local.php.example`
- `config/.htaccess`: zusätzliche Webserver-Sperre für den gesamten Konfigurationsordner; beide Deployment-Workflows schließen `secrets.local.php` explizit vom synchronisierenden Löschen aus.
- `partials/`: Templates, aufgeteilt in `landing/` und `structure/`
- `public/`: Webroot mit Assets und Seiten-Entry-Points
- `src/`: Backend-/Business-Logik und Kernklassen
- `debug/`: Debug-Hilfen und Logs
- `api/`: technische Endpunkte
- `Internal/`: interne Projektdokumentation in Deutsch und Englisch
- `Internal/db/`: internes Datenbankschema
- `Internal/archive/`: archivierte frühere interne Root-Dateien
## Aktuelle Strukturhinweise
- Avatar-Verwaltung: `src/App/Avatar/AvatarManager.php`
- aktive Styles: `src/App/Avatar/Lorelei.php`, `src/App/Avatar/Croodles.php`
- Profil- und Admin-Bereiche mit getrennten Seitennavigationen: `partials/landing/account/dashboard.php`, Zugriffskontext in `src/App/AccountPages.php`, Einstieg über `partials/structure/nav.php`
- Admin-Freigaben, Community-Moderation, User Management und Migration werden im Dashboard durch `src/App/AccountPages.php` verarbeitet und in `partials/landing/account/dashboard.php` dargestellt.
- Event-Quellen aus Datenbank plus Speicherung der Event-Location-Herkunft: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `Internal/db/schema.sql`
- Consent- und rechtliche Texte: `public/page/datenschutz.php`
- System-Einstellungen: `src/App/SystemSettings.php`
- Profil-Levels für Owner inklusive Benutzersuche, manueller Community-Punktevergabe und Liste kürzlich erreichter hoher Ränge: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `src/App/Community.php`, `src/App/CommunityAccess.php`, `src/App/SystemSettings.php`
- neue Listing-/Ort-Basis: `src/App/ListingCatalog.php`
- Kalender-Export und abonnierbarer Feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` und `public/page/calendar/feed.php`
- UI für getrennte Bereiche `Events` und `Orte & Veranstaltungen` im Mitgliederbereich: `partials/landing/account/dashboard.php`
- eigener Mitgliederbereichspunkt `Kategorien` für neue Kategorien und deren Zusammenführung per Vorschlagsfeld: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`
- Community-Level-Rechte für Kategorien und Orts-/Veranstaltungsfreigaben: `config/community.php`, `src/App/Community.php`, `src/App/CommunityAccess.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
- Preis-, Bild- und Ortseingabe für Orte und Veranstaltungen sowie reduzierte Event-Maske: `partials/landing/account/dashboard.php` plus Speicherung in `src/App/ListingCatalog.php`
- Freigaben sowie Änderungs- und Löschanfragen für Orte und Veranstaltungen: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
- Direktbearbeitung noch nicht veröffentlichter Orts- und Veranstaltungsvorschläge sowie Rechteauflösung für deren Freigabe: `src/App/CommunityAccess.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php`
- Server-seitige Ortsanbieter-Suche und sichere Trefferauswahl für die Admin-Freigabe: `src/App/PlaceProviderLookup.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php`
- Admin-Listen für bestehende Ortsbearbeitung und fehlende Anbieter-Verknüpfungen: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
- Direkte Admin-Korrekturen veröffentlichter Orts- und Veranstaltungsdaten: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
- Rollenvergabe per Benutzersuche im User Management: `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`, `src/App/CommunityAccess.php`
- Legacy-Eigen-Events wurden erweitert in `src/App/AccountPages.php` und `Internal/db/schema.sql` um Kategorie- und Bildfelder
- Kategorien-Prüfung und Zusammenführung für Seiten-Admins liegen ebenfalls in `partials/landing/account/dashboard.php` mit Logik in `src/App/ListingCatalog.php`
## Dokumentationsregel
Kanonische interne Dokumentation:
- `Internal/de/README.md`
- `Internal/de/PROJECT_CONTEXT.md`
- `Internal/de/PROJECT_STRUCTURE.md`
- entsprechende englische Spiegel unter `Internal/en/`
Zusätzlich:
- `README.md` im Hauptordner bleibt als Start-Doku für neue Chats bestehen.
- Änderungen an der internen Struktur müssen immer in Deutsch und Englisch parallel dokumentiert werden.
- Frühere interne Root-Dokumente liegen jetzt unter `Internal/archive/legacy-root-files/`.

76
Internal/de/README.md Normal file
View File

@@ -0,0 +1,76 @@
# Papa-Kind-Treff
Stand: 2026-08-10
Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale Events, Termine und Treffen, Community-Austausch und einen geschützten Mitgliederbereich.
## Produktumfang
- lokale Events, Termine und Treffen finden
- Community/Forum mit Kategorien, Boards, Threads und Antworten
- Mitgliederbereich für Profil, optionale Kinderinfos und eigene Events
- Community-Moderation mit Rollenmodell
## Aktueller Stand
- Startseite mit Hero, Event-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
- standortbasierte Sortierung für die neuesten Events
- Mitgliederbereich mit linker Bereichsnavigation
- Profilmenü oben rechts mit `Profil` sowie für Berechtigte `Admin-Einstellungen`
- Profilbereich für persönliche Angaben, Kinder, eigene Events, eigene Orte und Veranstaltungen, Einstellungen und Community-Informationen
- Separater Admin-Bereich für Profil-Levels, Kategorien, System-Einstellungen sowie Moderation und Freigaben von Orten und Veranstaltungen
- Admin-Unterbereiche bleiben im selben Dashboard-Layout getrennt: Freigaben zeigen nur noch nicht veröffentlichte Orte und Veranstaltungen, Community-Moderation nur offene Bewerbungen und Meldungen, User Management enthält Rollen und System-Levels, System enthält die Community-Migration.
- Konto-E-Mails sowie sensible Profilfelder werden app-seitig verschlüsselt gespeichert
- Kinder können angelegt, bearbeitet und gelöscht werden
- Profiladresse mit verschlüsselter Straße/Hausnummer, Adresssuche, Browser-Übernahme und Validierung
- System-Sektion für Seiten-Admins mit Wartungs- und Diensteschaltern
- neue Datenbasis für ein späteres Termin-, Ort- und Veranstaltungssystem angelegt
- Mitgliederbereich `Events` ist wieder auf echte eigene Events und Event-Teilnahmen fokussiert
- Eigene Events haben jetzt optionalen Beschreibungstext, klare Kinderangabe, optionale Platzzahl, optionales Bild sowie eine Location aus Datenbank oder benutzerdefinierte Adresse
- Bei eigener Event-Location aus der Datenbank werden Adresse und Kategorie nur informativ übernommen und nicht direkt im Event geändert
- Der Zwischenschritt vom eigenen Event zum Anlegen eines neuen Orts oder einer neuen Veranstaltung springt danach wieder in das Event zurück und wählt den neuen Eintrag direkt aus
- Orte und sonstige Veranstaltungen wurden vorerst in einen separaten Bereich `Orte & Veranstaltungen` verschoben
- Neue Orte und Veranstaltungen müssen vor Veröffentlichung erst durch einen Admin freigegeben werden; Änderungs- und Löschwünsche laufen ebenfalls nur als begründete Anfrage
- Noch nicht veröffentlichte Orts- und Veranstaltungsvorschläge können im Mitgliederbereich direkt weiterbearbeitet werden
- `SiteOwner` übernimmt bei Orts- und Veranstaltungsanfragen vollständig die Freigaberechte von `Forum-Admin`
- Die Eingabemasken sind jetzt progressiv aufgebaut: erst Pflichtangaben, optionale Daten in einklappbaren Bereichen
- Kategorien laufen jetzt über eine Sucheingabe mit bestehenden Vorschlägen; neue Kategorien werden automatisch angelegt und können im Systembereich von Seiten-Admins zusammengeführt werden
- Im Bereich `Events` gibt es jetzt zusätzlich einen ICS-Download und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen
## Wording-Regel
- Primärbegriff im Produkt: `Events`
- Ergänzende SEO-/Erklärbegriffe: `Termine`, `Treffen`
- Navigation, Buttons und Kern-UI verwenden konsequent `Events`
## Technik
- Einstieg: `public/index.php`
- Templates: `partials/`
- App-Logik: `src/App/`
- Assets: `public/assets/`
- Datenbankschema: `Internal/db/schema.sql`
- neue Basisklassen: `src/App/SystemSettings.php`, `src/App/ListingCatalog.php`
## Datenschutz und Sicherheit
- Server-Geheimnisse können lokal über `config/secrets.local.php` außerhalb des Webroots geladen werden; die Datei wird nicht versioniert und bei Deployments nicht gelöscht.
- Passwort-Hashes über Argon2id
- Konto-E-Mails verschlüsselt plus separater HMAC-Lookup-Hash
- persönliche Kalender-Feed-Tokens verschlüsselt plus separater SHA-256-Lookup-Hash
- Profilfelder wie Vorname, Nachname, Straße/Hausnummer, Telefonnummer, Beruf, Sprachen und Kurzvorstellung verschlüsselt
- Standortkoordinaten (`lat`, `lng`) bleiben für Distanz- und Umkreissuchen technisch im Klartext
- nicht notwendige Analyse- und Drittanbieterfunktionen erst nach Einwilligung aktiv
## Dokumentationspflicht bei Änderungen
Bei jeder Änderung im Projekt mitprüfen und bei Bedarf aktualisieren:
- `Internal/de/README.md`
- `Internal/de/PROJECT_CONTEXT.md`
- `Internal/de/PROJECT_STRUCTURE.md`
- die parallelen englischen Dateien unter `Internal/en/`
## Parallelpflege DE / EN
- Die Dokumentation in `Internal/de/` und `Internal/en/` ist inhaltlich vollständig parallel zu halten.
- Deutsch und Englisch sind beide verbindliche interne Referenzen.
- Die Root-`README.md` im Hauptordner bleibt der feste Einstieg für neue Chats und verweist auf diese Struktur.
## Root-Regel
- Im Root liegt für neue Chats inhaltlich nur noch die `README.md` als Einstieg.
- Frühere Root-Dokumente wurden nach `Internal/archive/legacy-root-files/` verschoben.

View File

@@ -0,0 +1,90 @@
# Papa-Kind-Treff Project Context
Updated: 2026-08-07
## Summary
Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events, appointments, and meetups, a community forum, member profiles with optional child information, and a moderation/admin area.
## Key Product Decisions
- Main navigation remains intentionally compact: `Home`, `Event Search`, `Community`.
- `Events` is the primary product term across the UI.
- Secondary wording is used only in SEO and explanatory copy.
- The community is hierarchical: categories -> boards -> threads -> posts.
- Moderation is separated from the normal community frontend.
- Location features are important for local discovery.
- The existing `events` logic remains active for now, but future growth should move toward a broader listing, place, and occurrence model.
- Site admins get a dedicated system section for global operating and service flags.
## Account / Member Area
- The top-right profile menu only contains `Profile`, `Admin Settings` for authorized users, and `Logout`.
- The profile area contains personal information, children, own events, own places and event-style entries, cookie/account settings, and community information.
- The admin area is separated from the profile area and contains, depending on permissions, profile levels, categories, system settings, community moderation, and listing approvals.
- Admin subsections stay within the same dashboard layout. Listing approvals only show not-yet-published places and event-style entries and allow direct editing; community moderation only shows open applications and reports; roles and system levels live in User Management, while the community migration lives under System.
- Existing direct links to `Categories`, `Profile Levels`, and `System` are automatically assigned to the admin area.
- Children can be created, edited, and deleted.
- If a birth date is set, age is calculated automatically and updated later when needed.
- Account email is encrypted application-side and addressed through a separate HMAC lookup hash.
- Profile address can be completed via address search or browser-based location import.
- The `Events` area is limited again to real own events and event participations.
- The `Events` area now also provides a manual ICS export and a personal subscribable calendar feed for all own events and event participations.
- The `Events` area is now separated in the UI into `My Events`, `Calendar Sync`, and `Expired Events` so that active events, calendar tasks, and past items stay clearly separated.
- Inside `My Events`, the `Create New Event` button now lives directly in the upper tab row; the previous participation box is now labeled `Registered Events` and also includes a direct link to `Event Search`.
- Places and event-like entries were moved for now into a separate member-area section `Places & Events`.
- Inside `Places & Events`, users can create permanent places and time-limited event-style entries.
- New places and event-style entries stay in a pending state until an admin approves them for publication.
- Not-yet-published place and event-style suggestions can be edited directly by their creator inside the member area.
- Change and deletion wishes for published places and event-style entries always run through a reasoned moderation request.
- Pending new places and event-style entries can already be used as location suggestions for events while they are still under review.
- Duplicate submissions for places and event-style entries should be blocked server-side at least by name and address.
- The `Events` input flow should stay as simple as possible and therefore only shows fields relevant for real scheduled events.
- Own events now only require title, date, and the `with children` choice; the description is optional.
- Event locations now support the modes `place from database`, `event from database`, or `custom`.
- When a database-backed place or event is selected, the address and category are taken directly from that source entry.
- Database-backed event locations now use a real select field instead of free text; `place from database` only shows places, while `event from database` only shows currently active event-style entries inside their valid date window.
- Inside the own-event form, `valid until` and `recurrence` are hidden.
- When an own event uses a database-backed location, address and category are shown as informational values and are not edited directly.
- The own-event form now offers direct links into creating a new place or a new event-style listing.
- After that intermediate step, the user returns to the own-event form with the previous event input restored and the newly created place or event-style listing already selected.
- When `event from database` is selected, the event date must fall inside that source event's valid date window.
- Categories in the member area are handled via a search input with existing suggestions and automatic creation when needed.
- Own events support an optional description, a clear `with children` yes/no field, optional capacity, an optional image, and either a database-backed location or a custom address.
- `Profile Levels` is an owner-only area for community levels and the overview of system levels.
- Community levels are now editable through `system_settings.community_levels_json` and currently carry the rights `highlight helpful replies` and `apply for forum admin`.
- Inside `Profile Levels`, the owner can additionally search users and manually increase community points with a required reason.
- `Profile Levels` also includes a list of users who reached at least the rank `Pillar of the Fathers Community` within the last 30 days.
- System levels remain separate as the roles `Forum Admin`, `Site Admin`, and `SiteOwner`.
- `Forum Admin` can already confirm and merge categories as well as approve places and event-style entries.
- `SiteOwner` and `Site Admin` fully inherit those approval rights.
- System-level assignment in `Community Admin` now runs through user search instead of raw user IDs; site admins remain limited by the existing role rules.
- Categories no longer live under `System`, but in their own member-area section `Categories`; that area only shows newly created categories and merges them into existing database categories through a suggestion field.
- New categories remain visible there until an authorized user confirms them or merges them into an existing category.
- The `Categories` area also includes a search across existing categories so they can be found and merged directly.
- During merging, the category that should not be kept is removed completely and the view is reloaded fresh afterwards.
- Merged categories are additionally blocked internally through redirects so default seeding or later inputs cannot make them selectable again.
- During merging, it is now possible to explicitly choose which of the two categories should be kept.
- Community levels can now additionally carry the rights for category handling as well as place and event approvals.
- Existing stored community levels receive these two rights automatically in the fallback from 750 points upward, unless they were explicitly saved differently.
## Technical Frame
- External API keys are provided only through server environment variables or the local, unversioned `config/secrets.local.php` file outside the webroot.
- custom front controller in `public/index.php`
- templates under `partials/`
- business logic under `src/App/`
- global frontend mainly via `public/assets/js/app.js` and `public/assets/css/app.css`
## New Expansion Stage: Listings and Places
- New table base: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits`, `listing_categories`
- Goal: later distinguish between own event, partner offer, general place, and editorial event entry
- In admin approvals, enabled place providers are searched server-side in parallel: OpenStreetMap/Nominatim, Google Places, and Azure Maps. An admin deliberately selects a match; links to multiple providers can be saved for one place. Google aggregate rating and rating count are loaded only after the selected Google link.
- The admin view for places and events has the sub-items `Approvals`, `Existing place editing` with a search field, and `Missing information`. The latter filters published entries by links missing from at least one currently enabled provider.
- After an external match is accepted, its temporary result list is removed. The Edit button is available in each of these subviews to check the name and address.
- Authorized admins can correct published places and events directly. If the full OSM query returns no match, simplified address and name-city queries are tried as well.
- A manual admin lookup sends the open entry's name and address data to enabled place providers; privacy and consent notices must be reviewed before enabling this in production.
## Deferred To-dos
- Large calendar-provider integrations should be added later in addition to the ICS feed, starting primarily with Google and Microsoft/Outlook.
- The target model is no reverse sync, but a user-side OAuth login through Papa-Kind-Treff followed by a direct calendar connection or event synchronization at the respective provider.
## Maintenance Note
- Whenever one of these files changes, the matching files in `Internal/de/` and `Internal/en/` must be kept aligned.
- The root `README.md` acts as the startup document for new chats and should be reviewed alongside major context changes.

View File

@@ -0,0 +1,45 @@
# Papa-Kind-Treff Project Structure
Updated: 2026-08-10
## Main Directories
- `config/`: configuration, including `prod/` and `staging/`
- `config/secrets.local.php`: local, unversioned server secrets outside the webroot; template: `config/secrets.local.php.example`
- `config/.htaccess`: additional web-server protection for the entire configuration directory; both deployment workflows explicitly exclude `secrets.local.php` from synchronizing deletion.
- `partials/`: templates split into `landing/` and `structure/`
- `public/`: webroot, assets, and page entry points
- `src/`: backend and business logic
- `debug/`: debugging helpers and logs
- `api/`: technical endpoints
- `Internal/`: internal documentation in German and English
- `Internal/db/`: internal database schema
- `Internal/archive/`: archived former internal root files
## Current Structure Notes
- avatar management: `src/App/Avatar/AvatarManager.php`
- active avatar styles: `src/App/Avatar/Lorelei.php`, `src/App/Avatar/Croodles.php`
- separated profile and admin navigation inside the member area: `partials/landing/account/dashboard.php`, access context in `src/App/AccountPages.php`, entry through `partials/structure/nav.php`
- admin approvals, community moderation, user management, and migration are processed in `src/App/AccountPages.php` and rendered within `partials/landing/account/dashboard.php`.
- database-backed event location sources plus persisted event location origin: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `Internal/db/schema.sql`
- consent and legal texts: `public/page/datenschutz.php`
- system settings: `src/App/SystemSettings.php`
- owner profile-level management including user search, manual community-point increases, and recently reached high-rank lists: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `src/App/Community.php`, `src/App/CommunityAccess.php`, `src/App/SystemSettings.php`
- new listing/place base: `src/App/ListingCatalog.php`
- calendar export and subscribable feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` and `public/page/calendar/feed.php`
- separated `Events` and `Places & Events` UI in member area: `partials/landing/account/dashboard.php`
- dedicated member-area section `Categories` for new categories and merging through a suggestion field: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`
- community-level rights for categories and place/event approvals: `config/community.php`, `src/App/Community.php`, `src/App/CommunityAccess.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
- pricing, image upload, and place/address input for places and event-style entries plus a reduced own-event form: `partials/landing/account/dashboard.php` with persistence in `src/App/ListingCatalog.php`
- approvals as well as change and deletion requests for places and event-style entries: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
- direct editing for not-yet-published place and event-style suggestions plus approval-right resolution: `src/App/CommunityAccess.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php`
- server-side place-provider lookup and secure match selection for admin approvals: `src/App/PlaceProviderLookup.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php`
- admin lists for existing place editing and missing provider links: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
- direct admin corrections of published place and event data: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
- role assignment via user search in User Management: `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`, `src/App/CommunityAccess.php`
- legacy own events were extended in `src/App/AccountPages.php` and `Internal/db/schema.sql` with category and image fields
## Documentation Rule
- canonical internal documentation lives in `Internal/de/` and `Internal/en/`
- `README.md` in the project root remains as the startup document for new chats
- structural changes must always be documented in German and English in parallel
- former internal root documentation files now live under `Internal/archive/legacy-root-files/`

75
Internal/en/README.md Normal file
View File

@@ -0,0 +1,75 @@
# Papa-Kind-Treff
Updated: 2026-08-10
Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, appointments, and meetups, community exchange, and a protected member area.
## Scope
- discover local events, appointments, and meetups
- community/forum with categories, boards, threads, and replies
- member area for profile, optional child information, and own events
- community moderation with a role model
## Current State
- landing page with hero, event search, event carousel, community preview, and member-area block
- community with board navigation and separate thread view
- community admin area for applications, reports, roles, and migrations
- location-based ordering for the newest events
- member area with left-side section navigation
- top-right profile menu with `Profile` and, for authorized users, `Admin Settings`
- profile area for personal information, children, own events, own places and event-style entries, settings, and community information
- separate admin area for profile levels, categories, system settings, and moderation and approvals for places and event-style entries
- Admin subsections stay inside the same dashboard layout: approvals only show not-yet-published places and event-style entries, community moderation only shows open applications and reports, User Management contains roles and system levels, and System contains the community migration.
- account emails and sensitive profile fields are stored encrypted application-side
- children can be created, edited, and deleted
- profile address supports encrypted street/house number, address search, browser import, and validation
- system section for site admins with maintenance and service toggles
- new data foundation prepared for a broader event, place, and listing system
- member area `Events` is now limited again to real own events and event participations
- own events now support an optional description, explicit child suitability, optional capacity, an optional image, and either a database-backed location or a custom address
- when an own event uses a database-backed location, address and category are shown as informational values and are not edited directly inside the event
- when a user creates a new place or event-style listing from inside an own event, the flow returns to that event afterwards and preselects the newly created entry
- places and other event-style entries were moved for now into a separate member-area section `Places & Events`
- new places and event-style entries must be approved by an admin before publication; change and deletion wishes also run as reasoned requests
- not-yet-published place and event-style suggestions can be edited directly in the member area
- `SiteOwner` fully inherits the place and event approval rights from `Forum Admin`
- the member-area entry forms now follow a progressive approach: required fields first, optional data inside collapsible sections
- categories now use a search input with existing suggestions; new categories are created automatically and can be merged by site admins in the system area
- the `Events` area now also includes an ICS download and a personal subscribable calendar feed for all own events and event participations
## Core Wording Rule
- Primary product term: `Events`
- Secondary SEO/explanatory terms: `Appointments`, `Meetups`
## Tech
- entry point: `public/index.php`
- templates: `partials/`
- application logic: `src/App/`
- assets: `public/assets/`
- database schema: `Internal/db/schema.sql`
- new base classes: `src/App/SystemSettings.php`, `src/App/ListingCatalog.php`
## Privacy and Security
- Server secrets can be loaded locally through `config/secrets.local.php` outside the webroot; the file is not versioned and is not deleted by deployments.
- password hashes via Argon2id
- account emails are encrypted and additionally addressed through a separate HMAC lookup hash
- personal calendar-feed tokens are stored encrypted and additionally resolved through a separate SHA-256 lookup hash
- profile fields such as first name, last name, street/house number, phone number, profession, languages, and short bio are encrypted
- location coordinates (`lat`, `lng`) remain plaintext where technically required for distance and radius search
- non-essential analytics and third-party services are only active after consent
## Documentation Rule
Whenever project behavior or structure changes, review and update:
- `Internal/de/README.md`
- `Internal/de/PROJECT_CONTEXT.md`
- `Internal/de/PROJECT_STRUCTURE.md`
- matching English files under `Internal/en/`
## Parallel DE / EN Maintenance
- Documentation in `Internal/de/` and `Internal/en/` must remain fully parallel in content.
- German and English are both binding internal references.
- The root `README.md` remains the fixed start document for new chats and must be reviewed when core project assumptions change.
## Root Rule
- In the root directory, only `README.md` remains as the content entry point for new chats.
- Former root documentation files were moved to `Internal/archive/legacy-root-files/`.

162
README.md Executable file → Normal file
View File

@@ -1,93 +1,91 @@
# emailtemplate
# Papa-Kind-Treff Start-Doku
Stand: 2026-08-10
Diese Datei ist die zentrale Start-Doku für neue Chats und den schnellen Projekteinstieg.
## Getting started
## Für neue Chats
Für neue Chats reicht es, zuerst diese Datei zu nennen und lesen zu lassen.
Diese Datei ist der verbindliche Root-Einstieg und verweist auf alle weiteren Pflichtdokumente unter `Internal/`.
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Danach muss die kanonische interne Dokumentation unter `Internal/` vollständig geprüft werden.
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)!
Verbindliche deutsche Referenz:
- `Internal/de/README.md`
- `Internal/de/PROJECT_CONTEXT.md`
- `Internal/de/PROJECT_STRUCTURE.md`
## Add your files
Parallele englische Referenz:
- `Internal/en/README.md`
- `Internal/en/PROJECT_CONTEXT.md`
- `Internal/en/PROJECT_STRUCTURE.md`
- [ ] [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:
## Dokumentationsregel
- Die Dokumentation unter `Internal/de/` und `Internal/en/` ist vollständig parallel zu pflegen.
- Änderungen an Projektstand, Struktur, Wording, Datenschutz, Sicherheit oder Produktverhalten müssen immer in Deutsch und Englisch nachgezogen werden.
- Die Root-`README.md` bleibt bewusst im Hauptordner, damit sie in neuen Chats direkt als Einstieg genannt werden kann.
```
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
```
## Kurzüberblick Projekt
Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf:
- lokale Events, Termine und Treffen
- Community-Austausch
- geschützten Mitgliederbereich mit Profil, Kinderinfos und eigenen Events
- neue Basis für ein übergreifendes Termin-, Ort- und Veranstaltungssystem
## Integrate with your tools
## Wichtige aktuelle Produktregeln
- Primärbegriff im Produkt: `Events`
- `Termine` und `Treffen` ergänzend in SEO- und Erklärungstexten
- Hauptnavigation aktuell: `Home`, `Event Suche`, `Community`
- Das Profilmenü oben rechts trennt klar zwischen `Profil` und dem nur für Berechtigte sichtbaren Punkt `Admin-Einstellungen`.
- Der Profilbereich bündelt persönliche Angaben, Kinder, eigene Events, eigene Orte und Veranstaltungen, Cookie-/Kontoeinstellungen sowie Community-Informationen.
- Der Admin-Bereich bündelt getrennt Profil-Levels, Kategorien, System-Einstellungen sowie die Moderation und Freigabe von Orten und Veranstaltungen.
- Innerhalb des Admin-Bereichs bleiben Freigaben, Community-Moderation, User Management, Profil-Levels und System im selben Dashboard-Layout getrennt; Rollen und System-Levels liegen im User Management, die Community-Migration im System.
- In `Profil-Levels` kann der `SiteOwner` Community-Level pflegen, Benutzer suchen und Community-Punkte manuell mit Begründung erhöhen
- In `Profil-Levels` werden zusätzlich Nutzer angezeigt, die in den letzten 30 Tagen mindestens den Rang `Säule der Väter-Community` erreicht haben
- Im Bereich `Community-Admin` läuft die Rollenvergabe jetzt über eine Benutzersuche statt über rohe Benutzer-IDs; `Site-Admins` bleiben dabei auf die vorhandene Rollenlogik begrenzt
- Kategorien wurden aus `System` in den eigenen Mitgliederbereichspunkt `Kategorien` verschoben; dort werden nur neue Kategorien angezeigt und per Vorschlagsfeld mit bestehenden Kategorien aus der Datenbank zusammengeführt
- Neue Kategorien bleiben im Bereich `Kategorien` sichtbar, bis sie von einem Berechtigten bestätigt oder zusammengeführt werden
- Im Bereich `Kategorien` gibt es zusätzlich eine Suche über bestehende Kategorien, damit sie gezielt gefunden und zusammengeführt werden können
- Beim Zusammenführen wird die nicht zu behaltende Kategorie anschließend vollständig entfernt und die Ansicht danach frisch neu geladen
- Zusammengeführte Kategorien werden zusätzlich intern als Redirect gesperrt, damit Standard-Seed oder spätere Eingaben sie nicht erneut auswählbar machen
- Beim Zusammenführen von Kategorien kann jetzt explizit festgelegt werden, welche der beiden Kategorien erhalten bleibt
- Community-Level können jetzt zusätzlich die Rechte für Kategorien sowie für die Freigabe von Orten und Veranstaltungen tragen
- Bestehende gespeicherte Community-Level erhalten diese beiden Rechte im Fallback automatisch ab 750 Punkten, solange sie nicht explizit anders gespeichert wurden
- neue interne Grundstruktur: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits`
- Seiten-Admins haben zusätzlich eine System-Sektion für globale Wartungs- und Diensteschalter
- Im Mitgliederbereich ist `Events` jetzt wieder auf echte eigene Events und Event-Teilnahmen beschränkt
- Orte und sonstige Veranstaltungen laufen jetzt separat unter `Orte & Veranstaltungen`
- Neue Orte und Veranstaltungen müssen vor Veröffentlichung erst durch einen Admin freigegeben werden; Änderungs- und Löschwünsche laufen ebenfalls als begründete Anfrage
- Noch nicht veröffentlichte Orts- und Veranstaltungsvorschläge können im Mitgliederbereich direkt weiterbearbeitet werden; veröffentlichte Einträge laufen für normale Mitglieder weiter über begründete Änderungsanfragen
- `SiteOwner` erbt bei Orts- und Veranstaltungsanfragen vollständig die Freigaberechte von `Forum-Admin`
- Eigene Events unterstützen aktuell optionalen Beschreibungstext, klare Kinderangabe, optionale Platzzahl, Bild-Upload sowie eine Location aus Datenbank oder benutzerdefinierte Adresse
- Bei eigener Event-Location aus der Datenbank werden Adresse und Kategorie nur informativ aus dem gewählten Ort bzw. der gewählten Veranstaltung übernommen und nicht manuell geändert
- Aus dem Formular für eigene Events kann direkt in das Anlegen eines neuen Orts oder einer neuen Veranstaltung verzweigt werden
- Wenn aus einem eigenen Event heraus ein neuer Ort oder eine neue Veranstaltung angelegt wird, kehrt der Ablauf danach wieder in das Event zurück und wählt den neuen Eintrag direkt vor
- Die Eingabe im Mitgliederbereich ist jetzt bewusst vereinfacht: zuerst nur Pflichtangaben, optionale Angaben in einklappbaren Bereichen
- Kategorien werden jetzt über eine Sucheingabe mit bestehenden Vorschlägen gepflegt; neue Kategorien werden automatisch angelegt und sind für Seiten-Admins im Systembereich zusammenführbar
- Im Mitgliederbereich `Events` gibt es jetzt zusätzlich einen ICS-Download und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen
- Der Bereich `Events` ist im Mitgliederbereich jetzt zusätzlich in die Tabs `Meine Events`, `Kalendersynchronisation` und `Abgelaufene Events` gegliedert
- Vorschläge für neue Orte und Veranstaltungen werden schon vor der Freigabe als mögliche Ortsauswahl für Events berücksichtigt, solange sie nicht abgelehnt oder archiviert wurden
- In der Admin-Freigabe können berechtigte Nutzer offene Orte und Veranstaltungen serverseitig bei aktivierten Anbietern (OpenStreetMap, Google Places, Azure Maps) suchen und einen Treffer gezielt verknüpfen; Google-Gesamtwertung und Bewertungsanzahl werden erst nach einer bewussten Google-Verknüpfung geladen.
- Die Admin-Ansicht für Orte und Veranstaltungen gliedert sich in `Freigaben`, `Bestehende Ortsbearbeitung` mit Suchfeld und `Fehlende Angaben`; Letztere listet veröffentlichte Einträge ohne Verknüpfung zu mindestens einem aktuell aktivierten Anbieter.
- Nach dem Übernehmen eines externen Treffers wird dessen temporäre Trefferliste entfernt. Der Button `Bearbeiten` steht in allen drei Admin-Unteransichten zur Prüfung von Name und Adresse bereit.
- Berechtigte Admins speichern Korrekturen an veröffentlichten Orten und Veranstaltungen direkt; für eine erfolglose OSM-Suche werden zusätzlich vereinfachte Adress- und Name-Ort-Abfragen versucht.
- Für später vorgemerkt: direkte Kalender-Anbindung großer Anbieter wie Google und Microsoft/Outlook per OAuth, zusätzlich zum bestehenden ICS-Feed
- Interne Projektdateien und das Datenbankschema liegen nicht mehr im Root, sondern unter `Internal/`
- [ ] [Set up project integrations](https://gitlab.int.kusche.berlin/emailtemplate/emailtemplate/-/settings/integrations)
## Datenschutz und Sicherheit
- Server-Geheimnisse werden optional aus `config/secrets.local.php` außerhalb des Webroots geladen; die Datei ist lokal, wird nicht versioniert und bei Deployments nicht gelöscht.
- Schlüssel für Ortsanbieter bleiben ausschließlich serverseitig. Die Auswahl von Suchtreffern erfolgt über kurzzeitig in der Server-Session abgelegte Treffer, nicht über vom Browser übermittelte Provider-IDs.
- Das Aktivieren von Ortsanbietern muss mit den Datenschutz- und Consent-Hinweisen abgeglichen werden, weil eine manuelle Admin-Suche Name und Adressdaten eines offenen Eintrags an den gewählten Anbieter überträgt.
- sensible Profilfelder werden app-seitig verschlüsselt gespeichert
- Konto-E-Mails werden verschlüsselt gespeichert und zusätzlich über einen separaten HMAC-Lookup-Hash adressiert
- persönliche Kalender-Feed-Tokens werden verschlüsselt gespeichert und zusätzlich über einen separaten SHA-256-Lookup-Hash adressiert
- Standortkoordinaten bleiben technisch im Klartext, soweit sie für Distanz- und Umkreissuchen erforderlich sind
- Änderungen an Cookies, LocalStorage, SessionStorage, Geolocation, Tracking oder Drittanbietern erfordern immer auch die Prüfung und Aktualisierung von Consent und rechtlichen Hinweisen
## Collaborate with your team
- [ ] [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.
## Hinweis zur internen Ablage
- Endnutzertexte, Landingpages und UI gehören nicht nach `Internal/`
- `Internal/` ist ausschließlich für interne Projekt- und Systemdokumentation gedacht
- Das Datenbankschema liegt unter `Internal/db/schema.sql`
- Alte frühere Root-Dokumente wurden nach `Internal/archive/legacy-root-files/` verschoben

7
config/.htaccess Normal file
View File

@@ -0,0 +1,7 @@
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Deny from all
</IfModule>

205
config/avatar.php Normal file
View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
return [
'dicebear' => [
'base_url' => 'https://api.dicebear.com',
'version' => '10.x',
'timeout' => 2,
'user_agent' => 'Papa-Kind-Treff/1.0',
],
'storage' => [
'cache_dir' => dirname(__DIR__) . '/public/assets/avatars/dicebear-cache',
'user_dir' => dirname(__DIR__) . '/public/assets/avatars/users',
'user_public_path' => '/assets/avatars/users',
],
'default_style' => 'lorelei',
'enabled_styles' => [
'lorelei',
'croodles',
],
'styles' => [
'lorelei' => [
'label' => 'Lorelei',
'class' => \App\Avatar\Lorelei::class,
'fixed_colors' => [],
'color_groups' => [
'eyesVariant' => ['eyesColor', 'skinColor', 'backgroundColor'],
'eyebrowsVariant' => ['eyebrowsColor'],
'mouthVariant' => ['mouthColor', 'noseColor', 'frecklesColor'],
'glassesVariant' => ['glassesColor'],
'hairVariant' => ['hairColor', 'hairAccessoriesColor'],
'beardVariant' => [],
'earringsVariant' => ['earringsColor'],
],
'component_controls' => [
'eyesVariant',
'eyebrowsVariant',
'mouthVariant',
'glassesVariant',
'hairVariant',
'beardVariant',
'earringsVariant',
],
'color_controls' => [
'earringsColor' => [
'label' => 'Ohrringe',
'default' => '#d6b36b',
'samples' => ['#d6b36b', '#b08d57', '#d9d9d9', '#8c6c4a'],
'allow_custom' => true,
],
'eyebrowsColor' => [
'label' => 'Augenbrauen',
'default' => '#4a3227',
'samples' => ['#4a3227', '#6a4938', '#2c221d', '#8a6651'],
'allow_custom' => true,
],
'eyesColor' => [
'label' => 'Augen',
'default' => '#2a2522',
'samples' => ['#2a2522', '#3f4f66', '#6b4d35', '#1f3a5f'],
'allow_custom' => true,
],
'frecklesColor' => [
'label' => 'Sommersprossen',
'default' => '#b67852',
'samples' => ['#b67852', '#a16645', '#c48a66', '#8f5a3e'],
'allow_custom' => true,
],
'glassesColor' => [
'label' => 'Brille',
'default' => '#26313f',
'samples' => ['#26313f', '#4d5f73', '#1b1f27', '#7a5d42'],
'allow_custom' => true,
],
'hairColor' => [
'label' => 'Haare',
'default' => '#4b3128',
'samples' => ['#4b3128', '#6f4b38', '#221916', '#b38a52'],
'allow_custom' => true,
],
'hairAccessoriesColor' => [
'label' => 'Haar-Accessoires',
'default' => '#b96f54',
'samples' => ['#b96f54', '#c98664', '#8d5a9e', '#5e8aa8'],
'allow_custom' => true,
],
'mouthColor' => [
'label' => 'Mund',
'default' => '#9f5a56',
'samples' => ['#9f5a56', '#b66b66', '#804444', '#c98a7a'],
'allow_custom' => true,
],
'noseColor' => [
'label' => 'Nase',
'default' => '#a56f58',
'samples' => ['#a56f58', '#bf8569', '#8d5f49', '#d3a17b'],
'allow_custom' => true,
],
'skinColor' => [
'label' => 'Haut',
'default' => '#e7ba9a',
'samples' => ['#f4d2ba', '#e7ba9a', '#c98f69', '#8a5638'],
'allow_custom' => true,
],
'backgroundColor' => [
'label' => 'Hintergrund',
'default' => '#f5efe5',
'samples' => ['#f5efe5', '#e9dfd1', '#d7e6ef', '#efe2d2'],
'allow_custom' => true,
],
],
'example_profile' => [
'seed' => 'pkt-demo-lorelei',
'components' => [
'eyesVariant' => 'variant08',
'eyebrowsVariant' => 'variant04',
'mouthVariant' => 'happy06',
'glassesVariant' => 'variant02',
'hairVariant' => 'variant18',
'beardVariant' => 'variant01',
'earringsVariant' => '',
],
'colors' => [
'skinColor' => '#e7ba9a',
'hairColor' => '#4b3128',
'eyesColor' => '#2a2522',
'backgroundColor' => '#f5efe5',
],
],
],
'croodles' => [
'label' => 'Croodles',
'class' => \App\Avatar\Croodles::class,
'fixed_colors' => [],
'color_groups' => [
'headVariant' => ['baseColor', 'backgroundColor'],
'eyesVariant' => ['eyepatchColor', 'glassesColor'],
'noseVariant' => [],
'mouthVariant' => [],
'topVariant' => ['topColor'],
'beardVariant' => [],
'mustacheVariant' => [],
],
'component_controls' => [
'headVariant',
'eyesVariant',
'noseVariant',
'mouthVariant',
'topVariant',
'beardVariant',
'mustacheVariant',
],
'color_controls' => [
'baseColor' => [
'label' => 'Basisfarbe',
'default' => '#ffffff',
'samples' => ['#ffffff', '#f4d2ba', '#e7ba9a', '#c98f69', '#8a5638'],
'allow_custom' => true,
],
'eyepatchColor' => [
'label' => 'Augenklappe',
'default' => '#283341',
'samples' => ['#283341', '#1a222d', '#4a5a6f', '#77583f'],
'allow_custom' => true,
],
'glassesColor' => [
'label' => 'Brille',
'default' => '#283341',
'samples' => ['#283341', '#4a5a6f', '#1a222d', '#74614a'],
'allow_custom' => true,
],
'topColor' => [
'label' => 'Top-Farbe',
'default' => '#4f6ea8',
'samples' => ['#4f6ea8', '#6d8fc2', '#36507a', '#6f8c55'],
'allow_custom' => true,
],
'backgroundColor' => [
'label' => 'Hintergrund',
'default' => '',
'samples' => [],
'allow_custom' => true,
],
],
'example_profile' => [
'seed' => 'pkt-demo-croodles',
'components' => [
'headVariant' => 'variant03',
'eyesVariant' => 'variant06',
'noseVariant' => 'variant04',
'mouthVariant' => 'variant09',
'topVariant' => 'variant11',
'beardVariant' => 'variant02',
'mustacheVariant' => '',
],
'colors' => [
'baseColor' => '#ffffff',
'topColor' => '#4f6ea8',
'glassesColor' => '#283341',
'backgroundColor' => '',
],
],
],
],
];

View File

@@ -41,8 +41,29 @@ return [
['min' => 150, 'label' => 'Unterstützender Vater'],
['min' => 300, 'label' => 'Erfahrener Vater'],
['min' => 500, 'label' => 'Mentor-Vater'],
['min' => 750, 'label' => 'Community-Vater'],
['min' => 1000, 'label' => 'Säule der Väter-Community'],
['min' => 1500, 'label' => 'Vater der Gemeinschaft'],
[
'min' => 750,
'label' => 'Community-Vater',
'rights' => [
'can_manage_categories' => true,
'can_review_listings' => true,
],
],
[
'min' => 1000,
'label' => 'Säule der Väter-Community',
'rights' => [
'can_manage_categories' => true,
'can_review_listings' => true,
],
],
[
'min' => 1500,
'label' => 'Vater der Gemeinschaft',
'rights' => [
'can_manage_categories' => true,
'can_review_listings' => true,
],
],
],
];

View File

@@ -1,6 +1,12 @@
<?php
declare(strict_types=1);
// Local server secrets live outside the public webroot and are never committed.
$localSecrets = __DIR__ . '/secrets.local.php';
if (is_file($localSecrets)) {
require_once $localSecrets;
}
// Basic error reporting (keep strict in dev)
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');

View File

@@ -37,5 +37,47 @@ $app = \App\App::get();
$app->session()->start();
$clientId = $app->session()->ensureClientId();
try {
$pdo = $app->pdo();
if ($pdo) {
$settings = new \App\SystemSettings($pdo);
$settings->ensureSchema();
if ($settings->getBool('site_maintenance_mode')) {
$isAdminUser = false;
if (isset($_SESSION['user_id'])) {
$communityCfg = __DIR__ . '/community.php';
$communityConfig = file_exists($communityCfg) ? require $communityCfg : [];
$communityAccess = new \App\CommunityAccess($pdo, $communityConfig);
$isAdminUser = $communityAccess->canManageApplications((int)$_SESSION['user_id']);
}
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$uriPath = preg_replace('~/{2,}~', '/', $uriPath);
$uriPath = trim($uriPath, '/');
$maintenanceWhitelist = [
'login',
'logout',
'verify',
'reset',
'impressum',
'datenschutz',
];
if (!$isAdminUser && !in_array($uriPath, $maintenanceWhitelist, true)) {
http_response_code(503);
header('Retry-After: 600');
$message = htmlspecialchars(
$settings->get('site_maintenance_message', 'Papa-Kind-Treff ist gerade kurz in Wartung. Bitte versuche es in Kürze erneut.') ?? '',
ENT_QUOTES
);
echo '<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Wartung</title><style>body{margin:0;font-family:sans-serif;background:#f6efe5;color:#243b53;display:grid;place-items:center;min-height:100vh;padding:24px}.card{max-width:680px;background:#fff;border:1px solid #e5d7c3;border-radius:20px;padding:28px;box-shadow:0 20px 60px rgba(36,59,83,.08)}h1{margin:0 0 12px;font-size:2rem}p{margin:0;line-height:1.6;color:#486581}</style></head><body><main class="card"><h1>Papa-Kind-Treff ist kurz in Wartung</h1><p>' . $message . '</p></main></body></html>';
exit;
}
}
}
} catch (\Throwable) {
// Fail open to avoid locking the whole site on transient settings/schema issues.
}
// Optionally expose a single global for templates if desired
$GLOBALS['client_id'] = $clientId;

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
// Copy this file to config/secrets.local.php and replace the placeholders.
// Never commit config/secrets.local.php or place it inside public/.
if (getenv('GOOGLE_MAPS_API_KEY') === false) {
putenv('GOOGLE_MAPS_API_KEY=replace-with-your-google-server-key');
}
if (getenv('AZURE_MAPS_SUBSCRIPTION_KEY') === false) {
putenv('AZURE_MAPS_SUBSCRIPTION_KEY=replace-with-your-azure-maps-key');
}

View File

@@ -11,9 +11,12 @@ if (!$userId) {
$communityCfg = require __DIR__ . '/../../../config/community.php';
$access = $pdo ? new \App\CommunityAccess($pdo, $communityCfg) : null;
$community = $pdo ? new \App\Community($pdo, $communityCfg) : null;
$migration = $pdo ? new \App\CommunityMigration($pdo) : null;
$listingCatalog = $pdo ? new \App\ListingCatalog($pdo) : null;
$communityPoints = $community ? $community->computePoints((int)$userId) : 0.0;
if (!$access || !$access->canModerateForum((int)$userId)) {
if (!$access || !$access->canAccessCommunityAdmin((int)$userId, $communityPoints)) {
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;
@@ -21,13 +24,25 @@ if (!$access || !$access->canModerateForum((int)$userId)) {
$error = '';
$info = '';
$canModerateForum = $access->canModerateForum((int)$userId);
$canManageApplications = $access->canManageApplications((int)$userId) && $access->supportsApplications();
$canManageRoles = $access->canManageRoles((int)$userId);
$canReviewListings = $listingCatalog !== null && $access->canReviewListings((int)$userId, $communityPoints);
$roleUserSearchQuery = trim((string)($_GET['role_user_query'] ?? ''));
$roleUserSearchResults = ($canManageRoles || $canManageApplications) && $roleUserSearchQuery !== ''
? $access->searchUsers($roleUserSearchQuery, 12)
: [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = (string)($_POST['action'] ?? '');
try {
if ($action === 'application_decide') {
if ($action === 'listing_request_decide') {
if (!$canReviewListings || !$listingCatalog) {
throw new \RuntimeException('Keine Berechtigung für Listing-Freigaben.');
}
$listingCatalog->decideModerationRequest((int)$userId, (int)($_POST['request_id'] ?? 0), (string)($_POST['decision'] ?? ''), (string)($_POST['review_note'] ?? ''));
$info = 'Listing-Anfrage wurde bearbeitet.';
} elseif ($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') {
@@ -51,6 +66,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
$listingRequests = $canReviewListings && $listingCatalog ? $listingCatalog->listOpenModerationRequests() : [];
$applications = $canManageApplications ? $access->listApplications('open') : [];
$reports = $access->supportsReports() ? $access->listOpenReports() : [];
$roleAssignments = $canManageRoles ? $access->listRoleAssignments() : [];
@@ -61,7 +77,7 @@ $migrationStatus = ($migration && $canManageApplications) ? $migration->status()
<div class="forum-breadcrumbs">
<a href="/">Home</a>
<span>/</span>
<a href="/dashboard">Mitgliederbereich</a>
<a href="/dashboard?area=admin&amp;section=admin-home">Admin-Einstellungen</a>
<span>/</span>
<span>Community-Admin</span>
</div>
@@ -72,7 +88,7 @@ $migrationStatus = ($migration && $canManageApplications) ? $migration->status()
<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>
<a class="btn ghost" href="/dashboard?area=admin&amp;section=admin-home">Zurück zu den Admin-Einstellungen</a>
</div>
</div>
@@ -84,6 +100,70 @@ $migrationStatus = ($migration && $canManageApplications) ? $migration->status()
<?php endif; ?>
<div class="forum-admin-grid">
<?php if ($canReviewListings): ?>
<section class="forum-board" id="listing-requests">
<div class="forum-board__head">
<div>
<h2>Offene Orts- und Veranstaltungsanfragen</h2>
<p class="muted">Neue Einträge sowie Änderungs- und Löschwünsche prüfen.</p>
</div>
</div>
<div class="forum-admin-list">
<?php foreach ($listingRequests as $request): ?>
<?php
$payload = is_array($request['payload'] ?? null) ? $request['payload'] : [];
$requestTypeLabel = match ((string)($request['request_type'] ?? '')) {
'create' => 'Neu',
'update' => 'Änderung',
'delete' => 'Löschung',
default => 'Anfrage',
};
?>
<article class="forum-admin-item">
<div>
<strong><?= htmlspecialchars((string)$requestTypeLabel, ENT_QUOTES) ?> · <?= htmlspecialchars((string)$request['title'], ENT_QUOTES) ?></strong>
<p class="muted small">Typ: <?= htmlspecialchars((string)(($request['listing_type'] ?? '') === 'place' ? 'Ort' : 'Veranstaltung'), ENT_QUOTES) ?> · Von <?= htmlspecialchars((string)($request['requested_by_name'] ?: 'Mitglied'), ENT_QUOTES) ?> · am <?= htmlspecialchars((string)$request['created_at'], ENT_QUOTES) ?></p>
<?php if (!empty($request['request_reason'])): ?>
<p><strong>Begründung:</strong><br><?= nl2br(htmlspecialchars((string)$request['request_reason'], ENT_QUOTES)) ?></p>
<?php endif; ?>
<p class="muted small">
<?= htmlspecialchars(trim(implode(' · ', array_filter([
trim(implode(', ', array_filter([
(string)($request['street'] ?? ''),
trim((string)($request['zip'] ?? '') . ' ' . (string)($request['city'] ?? '')),
]))),
(string)($request['region'] ?? ''),
]))), ENT_QUOTES) ?>
</p>
<?php if ($payload !== []): ?>
<div class="muted small" style="margin-top:8px;">
Vorgeschlagener Titel: <?= htmlspecialchars((string)($payload['title'] ?? $request['title']), ENT_QUOTES) ?><br>
Vorgeschlagene Adresse: <?= htmlspecialchars(trim(implode(', ', array_filter([
(string)($payload['street'] ?? ''),
trim((string)($payload['zip'] ?? '') . ' ' . (string)($payload['city'] ?? '')),
(string)($payload['region'] ?? ''),
]))), ENT_QUOTES) ?>
</div>
<?php endif; ?>
</div>
<form method="post" class="forum-admin-item__actions">
<input type="hidden" name="action" value="listing_request_decide">
<input type="hidden" name="request_id" value="<?= (int)$request['id'] ?>">
<textarea name="review_note" class="textarea" rows="3" placeholder="Hinweis für die Entscheidung"></textarea>
<div class="flex gap-12">
<button class="btn" type="submit" name="decision" value="approved">Freigeben</button>
<button class="btn ghost" type="submit" name="decision" value="rejected">Ablehnen</button>
</div>
</form>
</article>
<?php endforeach; ?>
<?php if (!$listingRequests): ?>
<div class="forum-empty">Keine offenen Orts- oder Veranstaltungsanfragen.</div>
<?php endif; ?>
</div>
</section>
<?php endif; ?>
<?php if ($canManageApplications): ?>
<section class="forum-board">
<div class="forum-board__head">
@@ -117,62 +197,95 @@ $migrationStatus = ($migration && $canManageApplications) ? $migration->status()
</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>
<?php if ($canModerateForum): ?>
<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>
<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>
<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 endif; ?>
<?php if ($canManageRoles): ?>
<?php if ($canManageRoles || $canManageApplications): ?>
<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">
<form method="get" class="form-grid single">
<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>
<label class="label" for="adminRoleUserQuery">Benutzer suchen</label>
<input id="adminRoleUserQuery" name="role_user_query" class="input" value="<?= htmlspecialchars($roleUserSearchQuery, ENT_QUOTES) ?>" placeholder="Name oder E-Mail">
</div>
<div>
<button class="btn" type="submit">Rolle vergeben</button>
<button class="btn" type="submit">Suchen</button>
</div>
</form>
<?php if ($roleUserSearchResults): ?>
<div class="stack gap-12" style="margin-top:18px;">
<?php foreach ($roleUserSearchResults as $candidate): ?>
<?php
$candidateUserId = (int)($candidate['id'] ?? 0);
$candidateRoles = $access->getUserRoles($candidateUserId);
$assignableRoles = $canManageRoles ? ['forum_admin', 'site_admin', 'owner'] : ['forum_admin'];
?>
<div class="card" style="padding:14px;">
<strong><?= htmlspecialchars((string)($candidate['display_name'] ?: trim(((string)($candidate['first_name'] ?? '')) . ' ' . ((string)($candidate['last_name'] ?? ''))) ?: 'Mitglied'), ENT_QUOTES) ?></strong>
<div class="muted small" style="margin-top:6px;">
ID: <?= $candidateUserId ?> · <?= htmlspecialchars((string)($candidate['email'] ?? ''), ENT_QUOTES) ?>
</div>
<?php if ($candidateRoles): ?>
<div class="muted small" style="margin-top:6px;">Aktuelle Rollen: <?= htmlspecialchars(implode(', ', array_map('strval', $candidateRoles)), ENT_QUOTES) ?></div>
<?php endif; ?>
<div class="flex gap-8" style="margin-top:12px; flex-wrap:wrap;">
<?php foreach ($assignableRoles as $roleKey): ?>
<?php if (!in_array($roleKey, $candidateRoles, true)): ?>
<form method="post">
<input type="hidden" name="action" value="role_assign">
<input type="hidden" name="target_user_id" value="<?= $candidateUserId ?>">
<input type="hidden" name="role" value="<?= htmlspecialchars($roleKey, ENT_QUOTES) ?>">
<button class="btn ghost" type="submit"><?= htmlspecialchars($roleKey, ENT_QUOTES) ?> vergeben</button>
</form>
<?php elseif ($canManageRoles): ?>
<form method="post">
<input type="hidden" name="action" value="role_revoke">
<input type="hidden" name="target_user_id" value="<?= $candidateUserId ?>">
<input type="hidden" name="role" value="<?= htmlspecialchars($roleKey, ENT_QUOTES) ?>">
<button class="btn ghost" type="submit"><?= htmlspecialchars($roleKey, ENT_QUOTES) ?> entziehen</button>
</form>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php elseif ($roleUserSearchQuery !== ''): ?>
<p class="muted small" style="margin-top:18px;">Keine passenden Benutzer gefunden.</p>
<?php endif; ?>
</div>
<div class="forum-admin-list">
<?php foreach ($roleAssignments as $assignment): ?>

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,7 @@ $emailPrefill = $vm['emailPrefill'] ?? '';
<aside class="auth-aside">
<img class="auth-logo" src="/assets/bilder/logo_male.png" alt="Papa-Kind-Treff Logo">
<h3>Neu hier?</h3>
<p class="auth-meta">Registriere dich kostenlos, lege dein Profil an und finde Treffen in deiner Nähe. Kinderinfos kannst du später hinzufügen.</p>
<p class="auth-meta">Registriere dich kostenlos, lege dein Profil an und finde Events, Termine und Treffen in deiner Nähe. Kinderinfos kannst du später hinzufügen.</p>
<div class="stack gap-12" style="margin-top: 12px;">
<a class="btn block" href="/register">Kostenlos registrieren</a>
<a class="btn ghost block" href="/">Zur Startseite</a>

View File

@@ -12,6 +12,9 @@ $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;
$systemSettings = $pdo ? new \App\SystemSettings($pdo) : null;
$forumMaintenanceMode = $systemSettings ? $systemSettings->getBool('forum_maintenance_mode') : false;
$isForumAdmin = ($access && $userId) ? $access->canModerateForum((int)$userId) : false;
$forumCategories = $community ? $community->listForumCategories() : [];
$selectedBoard = ($community && $boardSlug !== '') ? $community->getBoardBySlug($boardSlug) : null;
@@ -23,6 +26,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) {
if (!$userId) {
throw new \RuntimeException('Bitte einloggen, um Themen zu erstellen.');
}
if ($forumMaintenanceMode && !$isForumAdmin) {
throw new \RuntimeException('Die Community ist aktuell im Wartungsmodus. Neue Themen sind vorübergehend deaktiviert.');
}
if (!$access->canCreateThread((int)$userId)) {
$state = $access->getRestrictionState((int)$userId);
throw new \RuntimeException($state['reason'] ?: 'Du darfst aktuell keine Themen erstellen.');
@@ -67,8 +73,10 @@ $threads = $community
</div>
</form>
<div class="forum-hero__cta">
<?php if ($userId): ?>
<?php if ($userId && !$forumMaintenanceMode): ?>
<button class="btn" type="button" data-modal-open="modalThread">Neues Thema</button>
<?php elseif ($userId): ?>
<button class="btn ghost" type="button" disabled>Wartungsmodus aktiv</button>
<?php else: ?>
<a class="btn" href="/login">Einloggen und schreiben</a>
<?php endif; ?>
@@ -82,6 +90,11 @@ $threads = $community
<?php if ($info): ?>
<div class="toast-bar" style="margin-top:14px;"><?= htmlspecialchars($info, ENT_QUOTES) ?></div>
<?php endif; ?>
<?php if ($forumMaintenanceMode): ?>
<div class="toast-bar" style="margin-top:14px; border-color:#d4a857; color:#7c5a17;">
Die Community ist aktuell im Wartungsmodus. Lesen bleibt möglich, neue Themen und Antworten sind für normale Nutzer vorübergehend deaktiviert.
</div>
<?php endif; ?>
<?php if ($selectedBoard): ?>
<div class="forum-shell">
@@ -115,7 +128,7 @@ $threads = $community
?>
<article class="forum-row">
<div class="forum-row__topic">
<div class="forum-row__icon" aria-hidden="true">💬</div>
<div class="forum-row__avatar"><?= \App\Avatar\AvatarManager::render($t, (string)($t['display_name'] ?: 'Mitglied'), 'forum-list', (int)($t['uid'] ?? 0)) ?></div>
<div>
<h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3>
<div class="forum-row__meta">
@@ -206,7 +219,7 @@ $threads = $community
?>
<article class="forum-row">
<div class="forum-row__topic">
<div class="forum-row__icon" aria-hidden="true">💬</div>
<div class="forum-row__avatar"><?= \App\Avatar\AvatarManager::render($t, (string)($t['display_name'] ?: 'Mitglied'), 'forum-list', (int)($t['uid'] ?? 0)) ?></div>
<div>
<h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3>
<div class="forum-row__meta">
@@ -239,7 +252,7 @@ $threads = $community
</div>
</main>
<?php if ($userId): ?>
<?php if ($userId && !$forumMaintenanceMode): ?>
<div class="modal" id="modalThread">
<div class="panel">
<div class="head flex between center-y">

View File

@@ -12,6 +12,8 @@ $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;
$systemSettings = $pdo ? new \App\SystemSettings($pdo) : null;
$forumMaintenanceMode = $systemSettings ? $systemSettings->getBool('forum_maintenance_mode') : false;
$forumCategories = $community ? $community->listForumCategories() : [];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) {
@@ -21,6 +23,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) {
if (!$userId) {
throw new \RuntimeException('Bitte einloggen, um zu antworten.');
}
if ($forumMaintenanceMode && !($access && $access->canModerateForum((int)$userId))) {
throw new \RuntimeException('Die Community ist aktuell im Wartungsmodus. Antworten sind vorübergehend deaktiviert.');
}
if (!$access->canReply((int)$userId)) {
$state = $access->getRestrictionState((int)$userId);
throw new \RuntimeException($state['reason'] ?: 'Du darfst aktuell nicht antworten.');
@@ -73,7 +78,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) {
}
$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.');
throw new \RuntimeException('Dein Community-Level darf hilfreiche Antworten aktuell noch nicht hervorheben.');
}
$postId = (int)($_POST['post_id'] ?? 0);
$highlight = ((string)($_POST['highlight'] ?? '1')) === '1';
@@ -151,10 +156,15 @@ $userVotes = ($access && $userId) ? $access->getUserPostVotes((int)$userId, $pos
<?php if ($info): ?>
<div class="toast-bar" style="margin-bottom:14px;"><?= htmlspecialchars($info, ENT_QUOTES) ?></div>
<?php endif; ?>
<?php if ($forumMaintenanceMode): ?>
<div class="toast-bar" style="margin-bottom:14px; border-color:#d4a857; color:#7c5a17;">
Die Community ist aktuell im Wartungsmodus. Lesen bleibt möglich, neue Antworten nur für Admins.
</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>
<div class="forum-post__avatar"><?= \App\Avatar\AvatarManager::render($thread, $threadAuthor, 'forum', (int)($thread['user_id'] ?? 0)) ?></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>
@@ -203,7 +213,7 @@ $userVotes = ($access && $userId) ? $access->getUserPostVotes((int)$userId, $pos
?>
<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>
<div class="forum-post__avatar"><?= \App\Avatar\AvatarManager::render($p, $postAuthor, 'forum', (int)($p['user_id'] ?? 0)) ?></div>
<strong><?= htmlspecialchars($postAuthor, ENT_QUOTES) ?></strong>
<?php if ($isHighlighted): ?>
<span class="forum-highlight-badge">Hilfreich hervorgehoben</span>
@@ -290,7 +300,7 @@ $userVotes = ($access && $userId) ? $access->getUserPostVotes((int)$userId, $pos
<?php endif; ?>
</div>
<?php if ($userId): ?>
<?php if ($userId && !$forumMaintenanceMode): ?>
<?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: ?>
@@ -303,6 +313,8 @@ $userVotes = ($access && $userId) ? $access->getUserPostVotes((int)$userId, $pos
<button class="btn" type="submit">Antwort senden</button>
</form>
<?php endif; ?>
<?php elseif ($userId): ?>
<div class="forum-login-note">Die Community ist aktuell im Wartungsmodus. Antworten sind vorübergehend deaktiviert.</div>
<?php else: ?>
<div class="forum-login-note">Bitte <a href="/login">einloggen</a>, um zu antworten.</div>
<?php endif; ?>

View File

@@ -69,8 +69,8 @@ try {
<div class="container hero__single">
<div class="hero__text">
<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>
<p class="lede">Finde auf einen Blick lokale Events, Termine und Treffen 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 Events organisieren und dich unkompliziert zu bestehenden Treffen und Terminen dazugesellen.</p>
<div class="hero__actions hero__actions--center">
<a class="btn" href="/register">Gleich kostenlos registrieren</a>
</div>
@@ -88,7 +88,8 @@ try {
<section class="section alt" id="quicksearch">
<div class="container">
<div class="section__intro">
<h2>Suche nach Treffen in deiner Nähe</h2>
<h2>Events in deiner Nähe suchen</h2>
<p>Entdecke Vater-Kind-Events, lokale Termine und gemeinsame Treffen passend zu Ort, Thema und Entfernung.</p>
</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">
@@ -122,7 +123,8 @@ try {
<section class="container section" id="events">
<div class="section__intro">
<h2>Die neuesten Treffen</h2>
<h2>Die neuesten Events</h2>
<p>Neue Events, Termine und Treffen für Väter und Kinder in deiner Region.</p>
</div>
<div class="slider">
<button class="btn ghost slider__nav" type="button" data-slider-prev aria-label="Zurück"></button>
@@ -165,16 +167,16 @@ try {
<h3 class="mt-0">Kinder (optional)</h3>
<ul class="list">
<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>Events und Treffen besser einschätzen, wenn Aktivitäten zum Alter deiner Kinder passen sollen.</li>
<li>Nur für dich sichtbar.</li>
</ul>
</div>
<div class="surface border rounded p-4">
<h3 class="mt-0">Deine Termine</h3>
<h3 class="mt-0">Deine Events</h3>
<ul class="list">
<li>Veranstaltungen finden, speichern und schneller wiederfinden.</li>
<li>Eigene Treffen veröffentlichen und jederzeit aktualisieren.</li>
<li>Mit wenigen Angaben aus einer Idee einen konkreten Termin machen.</li>
<li>Events finden, speichern und schneller wiederfinden.</li>
<li>Eigene Events veröffentlichen und jederzeit aktualisieren.</li>
<li>Mit wenigen Angaben aus einer Idee einen konkreten Termin oder ein Treffen machen.</li>
</ul>
</div>
</div>

View File

@@ -10,6 +10,7 @@ $radius = ($radius > 0) ? $radius : 5.0;
$lat = isset($_GET['lat']) && $_GET['lat'] !== '' ? (float)$_GET['lat'] : null;
$lng = isset($_GET['lng']) && $_GET['lng'] !== '' ? (float)$_GET['lng'] : null;
$results = [];
$isLoggedIn = isset($_SESSION['user_id']);
function geocode_loc(string $loc): array
{
@@ -43,8 +44,11 @@ if ($pdo && ($q !== '' || $loc !== '' || ($lat !== null && $lng !== null))) {
?>
<main class="section">
<div class="container">
<p class="eyebrow">Suche</p>
<h1>Events finden</h1>
<h1>Events suchen</h1>
<p class="muted" style="max-width:72ch; margin:10px 0 0;">
Finde lokale Vater-Kind-Events, Termine, Papa-Treffen, Familienaktionen und gemeinsame Freizeitangebote in deiner Nähe.
Mit der Event Suche kannst du nach Ort, PLZ, Titel oder Beschreibung passende Events und Treffen schnell filtern.
</p>
<form method="get" class="form-grid single" style="margin: 14px 0; gap:12px; align-items:end;" id="searchForm">
<div class="stack gap-6">
<label class="label" for="q">Suchbegriff (Titel, Ort, Beschreibung)</label>
@@ -111,11 +115,20 @@ if ($pdo && ($q !== '' || $loc !== '' || ($lat !== null && $lng !== null))) {
<?php endforeach; ?>
</div>
<?php endif; ?>
<p class="muted" style="max-width:72ch; margin:16px 0 0;">
Nicht das passende gefunden?
<?php if ($isLoggedIn): ?>
<a href="/dashboard?section=events">Erstell doch dein eigenes Event</a>.
<?php else: ?>
<a href="/login">Logg dich ein</a> oder <a href="/register">registriere dich</a>, um eigene Events und Treffen anzulegen.
<?php endif; ?>
</p>
<?php endif; ?>
</div>
</main>
<script>
(function(){
const body = document.body;
const locInput = document.getElementById('loc');
const latInput = document.getElementById('lat');
const lngInput = document.getElementById('lng');
@@ -125,8 +138,64 @@ if ($pdo && ($q !== '' || $loc !== '' || ($lat !== null && $lng !== null))) {
const btnMap = document.getElementById('btnMap');
const mapWrapper = document.getElementById('mapWrapper');
const mapContainer = document.getElementById('mapContainer');
const isLoggedIn = body?.dataset.auth === '1';
const profileLocationPreference = body?.dataset.locationPreference || 'prompt';
const locationStorageKey = 'pkt_user_location';
const locationSessionStorageKey = 'pkt_user_location_session';
let map, marker;
function effectiveLocationPreference() {
if (!isLoggedIn) {
try {
const guestPreference = window.localStorage.getItem('pkt_location_preference_guest');
if (guestPreference && ['disabled', 'prompt', 'enabled'].includes(guestPreference)) {
return guestPreference;
}
} catch (err) {
// ignore
}
}
return profileLocationPreference;
}
function getStoredLocation() {
const candidates = [];
try {
const sessionValue = window.sessionStorage.getItem(locationSessionStorageKey);
if (sessionValue) candidates.push(sessionValue);
} catch (err) {
// ignore
}
try {
const localValue = window.localStorage.getItem(locationStorageKey);
if (localValue) candidates.push(localValue);
} catch (err) {
// ignore
}
for (const value of candidates) {
try {
const parsed = JSON.parse(value);
if (parsed && Number.isFinite(parsed.lat) && Number.isFinite(parsed.lng)) {
return parsed;
}
} catch (err) {
// ignore
}
}
return null;
}
function applyLocation(lat, lng, label = 'Mein Standort') {
latInput.value = Number(lat).toFixed(6);
lngInput.value = Number(lng).toFixed(6);
if (locInput && !locInput.value.trim()) {
locInput.value = label;
}
toggleRadius(true);
}
function toggleRadius(show) {
if (!radiusWrap) return;
radiusWrap.hidden = !show;
@@ -140,6 +209,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,17 +262,30 @@ 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;
}
const preference = effectiveLocationPreference();
if (preference === 'disabled') {
alert('Die Standortverwendung ist in deinem Profil deaktiviert.');
return;
}
const storedLocation = getStoredLocation();
if (preference === 'enabled' && storedLocation) {
applyLocation(storedLocation.lat, storedLocation.lng, storedLocation.label || 'Mein Standort');
return;
}
if (!navigator.geolocation) {
alert('Geolocation wird nicht unterstützt.');
return;
}
navigator.geolocation.getCurrentPosition((pos) => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
latInput.value = lat.toFixed(6);
lngInput.value = lng.toFixed(6);
if (locInput && !locInput.value.trim()) locInput.value = 'Mein Standort';
toggleRadius(true);
applyLocation(pos.coords.latitude, pos.coords.longitude, 'Mein Standort');
}, () => alert('Standort konnte nicht ermittelt werden.'));
});

View File

@@ -2,6 +2,7 @@
<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>

View File

@@ -14,15 +14,42 @@ if (!in_array($childGender, ['male', 'female', 'mixed'], true)) {
$debugEnabled = defined('APP_DEBUG') && APP_DEBUG === true;
$locationTrackingPreference = 'prompt';
$showDebugTools = false;
$adminSystemNotice = null;
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');
if ($communityAccess->canManageApplications((int)$_SESSION['user_id'])) {
$systemSettings = new \App\SystemSettings($pdo);
$siteMaintenanceMode = $systemSettings->getBool('site_maintenance_mode');
$forumMaintenanceMode = $systemSettings->getBool('forum_maintenance_mode');
$googlePlacesEnabled = $systemSettings->getBool('google_places_enabled');
$parts = [];
if ($siteMaintenanceMode) {
$parts[] = 'Seiten-Wartungsmodus aktiv';
}
if ($forumMaintenanceMode) {
$parts[] = 'Forum-Wartungsmodus aktiv';
}
if ($googlePlacesEnabled) {
$parts[] = 'Google-Places-Vorbereitung aktiv';
}
if ($parts !== []) {
$adminSystemNotice = implode(' · ', $parts);
}
}
}
} catch (\Throwable) {
$locationTrackingPreference = 'prompt';
$showDebugTools = false;
$adminSystemNotice = null;
}
}
$debugFiles = [];
@@ -45,7 +72,7 @@ if ($debugEnabled) {
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></title>
<meta name="description" content="Papa-Kind-Treff: Väter vernetzen sich für Treffen mit und ohne Kinder, Events in der Nähe entdecken und sicher Kontakte knüpfen.">
<meta name="description" content="Papa-Kind-Treff: Väter vernetzen sich für Events, Termine und Treffen mit und ohne Kinder, entdecken lokale Angebote und knüpfen sicher Kontakte.">
<?php if (defined('APP_ROBOTS')): ?>
<meta name="robots" content="<?= htmlspecialchars(APP_ROBOTS, ENT_QUOTES) ?>">
<?php endif; ?>
@@ -99,6 +126,11 @@ if ($debugEnabled) {
</script>
<?php tpl('matomo', 'structure'); ?>
<?php tpl('nav', 'structure'); ?>
<?php if ($adminSystemNotice !== null): ?>
<div class="container" style="margin-top:14px;">
<div class="toast-bar" style="border-color:#d4a857; color:#7c5a17;"><?= htmlspecialchars($adminSystemNotice, ENT_QUOTES) ?></div>
</div>
<?php endif; ?>
<div class="cookie-consent" id="cookieConsentBanner" hidden>
<div class="cookie-consent__inner">
<div class="cookie-consent__copy">
@@ -158,7 +190,7 @@ if ($debugEnabled) {
</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; }

View File

@@ -1,7 +1,41 @@
<?php
$app = app();
$isLoggedIn = isset($_SESSION['user_id']);
$isDebug = defined('APP_DEBUG') && APP_DEBUG === true;
$displayName = 'Profil';
$profileInitial = 'P';
$showAdminSettingsLink = false;
if ($isLoggedIn) {
try {
$pdo = $app->pdo();
if ($pdo) {
$profileSettings = new \App\ProfileSettings($pdo);
$profileSettings->ensureSchema();
$avatarColumns = implode(', ', array_map(
static fn(string $column): string => $column,
\App\Avatar\AvatarManager::allProfileColumns()
));
$stmt = $pdo->prepare("SELECT user_id, display_name, $avatarColumns 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'] ?? '')) ?: 'Profil';
$communityCfg = dirname(__DIR__, 2) . '/config/community.php';
if (file_exists($communityCfg)) {
$communityConfig = require $communityCfg;
$community = new \App\Community($pdo, $communityConfig);
$communityAccess = new \App\CommunityAccess($pdo, $communityConfig);
$navCommunityPoints = $community->computePoints((int)$_SESSION['user_id']);
$showAdminSettingsLink = $communityAccess->canAccessCommunityAdmin((int)$_SESSION['user_id'], $navCommunityPoints)
|| $communityAccess->canManageCategories((int)$_SESSION['user_id'], $navCommunityPoints);
}
}
} catch (\Throwable) {
$displayName = 'Profil';
$profileInitial = 'P';
$showAdminSettingsLink = false;
}
}
?>
<header class="site-header">
<div class="container nav-row">
@@ -14,17 +48,24 @@ $isDebug = defined('APP_DEBUG') && APP_DEBUG === true;
</div>
<nav class="nav-links" aria-label="Hauptmenü">
<a href="/">Home</a>
<a href="/search">Suche</a>
<a href="/#events" data-nav-events>Termine</a>
<a href="/search">Event Suche</a>
<a href="/community">Community</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"><?= \App\Avatar\AvatarManager::render($profileRow ?? [], $displayName, 'nav', (int)($_SESSION['user_id'] ?? 0)) ?></span>
<span class="profile-menu__name"><?= htmlspecialchars($displayName, ENT_QUOTES) ?></span>
</button>
<div class="profile-menu__dropdown" role="menu">
<a href="/dashboard?section=profile" role="menuitem">Profil</a>
<?php if ($showAdminSettingsLink): ?>
<a href="/dashboard?area=admin&section=admin-home" role="menuitem">Admin-Einstellungen</a>
<?php endif; ?>
<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>
@@ -34,15 +75,14 @@ $isDebug = defined('APP_DEBUG') && APP_DEBUG === true;
</div>
<div class="mobile-menu" id="mobileMenu">
<a href="/">Home</a>
<a href="/search">Suche</a>
<a href="/#events" data-nav-events>Termine</a>
<a href="/search">Event Suche</a>
<a href="/community">Community</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>
<a class="btn ghost" href="/dashboard?section=profile">Profil</a>
<?php if ($showAdminSettingsLink): ?>
<a class="btn ghost" href="/dashboard?area=admin&section=admin-home">Admin-Einstellungen</a>
<?php endif; ?>
<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>

View File

@@ -11,6 +11,7 @@ Options -Indexes
# 1) Assets DIREKT ausliefern
# -------------------------------------------------
RewriteRule ^assets/ - [L]
RewriteRule ^api/ - [L]
# -------------------------------------------------
# 2) page/ von außen sperren (nur intern per require nutzbar)

12
public/api/avatar.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/config/fileload.php';
use App\Avatar\AvatarManager;
$svg = AvatarManager::fetchSvgFromQuery($_GET);
header('Content-Type: image/svg+xml; charset=utf-8');
header('Cache-Control: public, max-age=604800, immutable');
echo $svg;

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; }
.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; }
}
@@ -79,6 +89,7 @@ body {
.muted.small { font-size: 13px; }
.section { padding: 64px 0; }
.section--dashboard { padding-top: 28px; }
.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; }
@@ -104,6 +115,24 @@ body {
.step { text-align: left; }
.step__icon { width: 34px; height:34px; border-radius: 10px; background: var(--color-accent); color: #fff; display:flex; align-items:center; justify-content:center; font-weight:700; margin-bottom: 8px; }
.pkt-avatar { --avatar-size: 52px; --avatar-skin: #efc1a3; --avatar-hair: #5b392b; --avatar-eye: #5f4431; --avatar-hat: #38475f; --avatar-outfit: #5f7387; position: relative; display: inline-block; width: var(--avatar-size); height: var(--avatar-size); border-radius: 20px; overflow: hidden; background: radial-gradient(circle at 50% 22%, #fffdfa 0%, #f7efe6 62%, #ecdac6 100%); border: 1px solid rgba(74, 52, 38, 0.12); box-shadow: 0 10px 24px rgba(39, 28, 21, 0.08); isolation: isolate; }
.pkt-avatar--nav { --avatar-size: 34px; border-radius: 999px; }
.pkt-avatar--forum-list { --avatar-size: 42px; border-radius: 14px; }
.pkt-avatar--forum { --avatar-size: 52px; border-radius: 16px; }
.pkt-avatar--xl { --avatar-size: 120px; border-radius: 28px; }
.pkt-avatar--xxl { --avatar-size: 168px; border-radius: 36px; }
.pkt-avatar__img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center top; }
.pkt-avatar--layered::before { content: ""; position: absolute; inset: auto 8% -8% 8%; height: 28%; border-radius: 50%; background: radial-gradient(circle at center, rgba(74, 52, 38, 0.16), transparent 70%); filter: blur(10px); z-index: 0; pointer-events: none; }
.pkt-avatar__layer { position: absolute; inset: 0; display: block; z-index: 1; pointer-events: none; }
.pkt-avatar__layer--outfit { z-index: 1; }
.pkt-avatar__layer--head { z-index: 2; }
.pkt-avatar__layer--hair { z-index: 3; }
.pkt-avatar__layer--hat { z-index: 4; }
.pkt-avatar__layer--features { z-index: 5; }
.pkt-avatar__layer--beard { z-index: 6; }
.pkt-avatar__layer--glasses { z-index: 7; }
.pkt-avatar__layer svg { display: block; width: 100%; height: 100%; }
.faq details { border:1px solid var(--color-border); border-radius: var(--radius-sm); padding: 10px 12px; background: #fff; }
.faq summary { cursor:pointer; font-weight: 600; }
.faq p { margin: 8px 0 0 0; color: var(--color-muted); }
@@ -197,12 +226,108 @@ body {
.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; }
.account-layout { display: grid; grid-template-columns: 290px minmax(0, 1fr); gap: 24px; align-items: start; }
.account-sidebar { position: sticky; top: 104px; }
.account-sidebar__inner { background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden; }
.account-sidebar__head { padding: 20px 20px 16px; border-bottom: 1px solid var(--color-border); background: linear-gradient(180deg, #fffdfa, #f8f4ec); }
.account-sidebar__head h2 { margin: 10px 0 0; font-size: 22px; }
.account-sidebar__nav { padding: 16px 14px; display: grid; gap: 6px; }
.account-sidebar__nav a { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 12px; color: var(--color-text); font-weight: 600; }
.account-sidebar__nav a:hover { background: #f8f4ec; color: var(--color-primary); }
.account-sidebar__nav a.is-active { background: var(--color-primary); color: var(--color-primary-contrast); }
.account-sidebar__nav .account-sidebar__back { margin-top: 10px; padding-top: 18px; border-top: 1px solid var(--color-border); color: var(--color-muted); }
.account-main { min-width: 0; display: grid; gap: 20px; }
.profile-summary-card--link { display: block; color: var(--color-text); transition: transform .18s ease, box-shadow .18s ease; }
.profile-summary-card--link:hover { color: var(--color-text); transform: translateY(-2px); box-shadow: var(--shadow-card); }
.account-panel { background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden; }
.account-panel__head { padding: 22px 24px; border-bottom: 1px solid var(--color-border); background: #fffdfa; }
.account-panel__head h2 { margin: 0 0 6px; }
.account-panel__body { padding: 24px; }
.account-inline-tabs-wrap { display:flex; justify-content:space-between; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:18px; }
.account-inline-tabs { display: flex; gap: 10px; flex-wrap: wrap; }
.account-inline-tabs__button { appearance: none; border: 1px solid var(--color-border); background: #fff; color: var(--color-text); border-radius: 999px; padding: 11px 16px; font: inherit; font-weight: 700; cursor: pointer; transition: background .15s ease, color .15s ease, border-color .15s ease; }
.account-inline-tabs__button:hover { border-color: var(--color-primary); color: var(--color-primary); }
.account-inline-tabs__button.is-active { background: var(--color-primary); color: var(--color-primary-contrast); border-color: var(--color-primary); }
.account-inline-tab-panel[hidden] { display: none; }
.account-kv { display: grid; gap: 10px; margin: 0; }
.account-kv div { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 14px; }
.account-kv--compact { gap: 8px; }
.account-kv--compact div { grid-template-columns: 120px minmax(0, 1fr); gap: 10px; }
.account-kv dt { font-weight: 700; color: var(--color-muted); }
.account-kv dd { margin: 0; }
.profile-avatar-panel { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 22px; align-items: center; margin-bottom: 24px; padding: 20px; background: linear-gradient(135deg, #fffdfa, #f7f0e6); border: 1px solid var(--color-border); border-radius: 18px; }
.profile-avatar-panel__preview { display: flex; justify-content: center; }
.profile-avatar-panel__content h3 { margin: 0 0 8px; }
.profile-avatar-panel__content p { margin: 0 0 14px; }
.profile-summary-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.profile-summary-card { padding: 18px; background: #fffdfa; border: 1px solid var(--color-border); border-radius: 18px; }
.profile-summary-card__eyebrow { display: inline-block; margin-bottom: 12px; font-size: 12px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--color-highlight); }
.profile-about-card { margin-top: 16px; padding: 18px; background: linear-gradient(135deg, #fff, #f8f4ec); border: 1px solid var(--color-border); border-radius: 18px; }
.profile-about-card strong { display: block; margin-bottom: 8px; }
.profile-about-card p { margin: 0; line-height: 1.65; color: var(--color-text); }
.profile-edit-form { max-width: 100%; }
.profile-edit-section { border: 1px solid var(--color-border); border-radius: 18px; background: #fffdfa; overflow: hidden; }
.profile-edit-section summary { cursor: pointer; list-style: none; padding: 16px 18px; font-weight: 800; color: var(--color-text); display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.profile-edit-section summary::-webkit-details-marker { display: none; }
.profile-edit-section summary::after { content: "+"; width: 28px; height: 28px; border-radius: 999px; border: 1px solid var(--color-border); display: inline-flex; align-items: center; justify-content: center; color: var(--color-primary); background: #fff; font-size: 18px; line-height: 1; flex: 0 0 auto; }
.profile-edit-section[open] summary::after { content: ""; }
.profile-edit-section__body { display: grid; gap: 14px; padding: 0 18px 18px; border-top: 1px solid rgba(196, 169, 120, 0.22); }
.avatar-builder { height: 100%; min-height: 0; }
.avatar-builder__shell { height: 100%; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 14px; }
.avatar-builder__top { display: grid; gap: 14px; padding: 14px; background: linear-gradient(180deg, #fffdfa, #f8f4ec); border: 1px solid var(--color-border); border-radius: 18px; }
.avatar-builder__hero { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 22px; align-items: center; }
.avatar-builder__hero-preview { display: flex; align-items: center; justify-content: center; }
.avatar-builder__hero-content { display: grid; gap: 14px; min-width: 0; }
.avatar-builder__actions { justify-content: flex-start; }
.avatar-builder__body { min-height: 0; overflow: hidden; display: grid; }
.avatar-style-group { min-height: 0; display: none; grid-template-rows: auto minmax(0, 1fr); gap: 12px; }
.avatar-style-group.is-active { display: grid; }
.avatar-component-nav { position: sticky; top: 0; z-index: 4; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; padding: 2px 0 10px; background: linear-gradient(180deg, rgba(246, 240, 228, 0.98), rgba(246, 240, 228, 0.92)); border-bottom: 1px solid rgba(196, 169, 120, 0.25); }
.avatar-component-nav__button { border: 1px solid var(--color-border); background: #fff; color: var(--color-text); border-radius: 999px; padding: 8px 12px; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
.avatar-component-nav__button.is-active { background: var(--color-primary); color: var(--color-primary-contrast); border-color: var(--color-primary); }
.avatar-loader { width: 100%; display: grid; gap: 8px; padding: 12px 14px; background: rgba(255,255,255,0.72); border: 1px solid var(--color-border); border-radius: 14px; }
.avatar-loader__bar { width: 100%; height: 10px; overflow: hidden; border-radius: 999px; background: #ece2d4; }
.avatar-loader__fill { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, #37485a, #68819a); transition: width 180ms ease; }
.avatar-loader__meta { display: flex; justify-content: space-between; gap: 10px; align-items: center; }
.avatar-component-panels { width: 100%; min-height: 0; display: grid; }
.avatar-component-panel { display: none; width: 100%; min-height: 0; gap: 12px; grid-template-rows: auto auto minmax(0, 1fr); }
.avatar-component-panel.is-active { display: grid; }
.avatar-component-panel__head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
.avatar-component-colors { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; padding: 10px 12px; background: rgba(255,255,255,0.72); border: 1px solid rgba(196, 169, 120, 0.35); border-radius: 16px; }
.avatar-color-control { display: grid; gap: 6px; align-content: start; }
.avatar-color-control__label { font-size: 12px; font-weight: 700; color: var(--color-text); }
.avatar-color-control__row { display: flex; align-items: center; gap: 8px; }
.avatar-color-control__picker { width: 38px; min-width: 38px; height: 38px; padding: 0; border: 1px solid var(--color-border); border-radius: 10px; background: #fff; cursor: pointer; }
.avatar-color-control__picker::-webkit-color-swatch-wrapper { padding: 4px; }
.avatar-color-control__picker::-webkit-color-swatch { border: 0; border-radius: 7px; }
.avatar-color-control__picker::-moz-color-swatch { border: 0; border-radius: 7px; }
.avatar-color-control__reset { min-height: 38px; padding: 8px 12px; font-size: 12px; }
.avatar-color-control__samples { display: flex; flex-wrap: wrap; gap: 6px; }
.avatar-color-sample { width: 24px; height: 24px; border: 1px solid rgba(0,0,0,0.12); border-radius: 8px; cursor: pointer; padding: 0; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18); }
.avatar-component-grid { min-height: 0; overflow: auto; padding-right: 4px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; align-content: start; }
.avatar-component-card { position: relative; display: grid; gap: 6px; padding: 8px; background: #fff; border: 1px solid var(--color-border); border-radius: 16px; cursor: pointer; }
.avatar-component-card input { position: absolute; opacity: 0; pointer-events: none; }
.avatar-component-card__thumb { display: block; aspect-ratio: 1; border-radius: 14px; overflow: hidden; background: linear-gradient(180deg, #f7efe6, #f0e4d7); border: 1px solid rgba(74, 52, 38, 0.08); }
.avatar-component-card__thumb img { width: 100%; height: 100%; object-fit: cover; object-position: center center; display: block; transform: none; }
.avatar-component-card__label { font-size: 12px; font-weight: 700; color: var(--color-text); text-align: center; }
.avatar-component-card:has(input:checked) { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(52,72,90,0.14); }
@media (max-width: 980px){ .avatar-component-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 860px){ .avatar-builder__hero { grid-template-columns: 1fr; } .avatar-builder__actions { justify-content: center; } .avatar-component-colors { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } .avatar-component-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .profile-summary-grid { grid-template-columns: 1fr; } }
.avatar-swatch-group { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
.avatar-swatch { display: grid; grid-template-columns: 20px 18px minmax(0, 1fr); gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 14px; background: #fff; cursor: pointer; }
.avatar-swatch__color { width: 18px; height: 18px; border-radius: 999px; background: var(--swatch-color); border: 1px solid rgba(0,0,0,0.12); }
.avatar-swatch input { margin: 0; }
.avatar-swatch:has(input:checked) { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(52,72,90,0.14); }
.settings-help { display: grid; gap: 14px; }
.settings-help details { border: 1px solid var(--color-border); border-radius: 12px; padding: 12px 14px; background: #fff; }
.settings-help summary { cursor: pointer; font-weight: 700; }
.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__topic { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 14px; }
.forum-row__avatar { display: flex; align-items: flex-start; justify-content: center; }
.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); }
@@ -222,7 +347,7 @@ body {
.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__avatar { width: 52px; height: 52px; display: flex; align-items: center; justify-content: center; }
.forum-post__author strong { font-size: 17px; }
.forum-post__author span { color: var(--color-muted); font-size: 14px; }
.forum-post__body { padding: 20px 24px; }
@@ -261,17 +386,21 @@ body {
.forum-list__header,
.forum-row { grid-template-columns: minmax(0, 1fr); }
.forum-shell { grid-template-columns: 1fr; }
.account-layout { grid-template-columns: 1fr; }
.forum-sidebar { position: static; }
.account-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); }
.profile-avatar-panel { grid-template-columns: 1fr; }
}
@media (max-width: 720px){
.nav-row { padding: 12px 0; }
.hero { padding: 40px 0; }
.section { padding: 48px 0; }
.section--dashboard { padding-top: 20px; }
.forum-hero,
.forum-board__head,
.forum-thread-head { grid-template-columns: 1fr; display: grid; }
@@ -285,9 +414,14 @@ body {
.forum-board-row,
.forum-admin-item { padding-left: 16px; padding-right: 16px; }
.forum-post__body,
.forum-reply-form { padding: 18px 16px; }
.forum-reply-form,
.account-panel__body { padding: 18px 16px; }
.cookie-consent__inner { grid-template-columns: 1fr; }
.cookie-consent__actions { justify-content: stretch; }
.account-kv div { grid-template-columns: 1fr; gap: 4px; }
.account-kv--compact div { grid-template-columns: 1fr; gap: 4px; }
.profile-edit-section summary,
.profile-edit-section__body { padding-left: 14px; padding-right: 14px; }
}
/* Auth & Dashboard */
@@ -301,10 +435,10 @@ body {
.form-grid.single { grid-template-columns: 1fr; }
@media (max-width: 900px){ .auth-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; } }
.modal .panel { max-height: 90vh; overflow: auto; }
.modal { position: fixed; inset: 0; display: none; align-items: flex-start; justify-content: center; padding: 70px 16px 24px; background: rgba(0,0,0,0.4); z-index: 200; overflow: auto; }
.modal { position: fixed; inset: 0; display: none; align-items: flex-start; justify-content: center; padding: 16px 8px; background: rgba(0,0,0,0.4); z-index: 200; overflow: hidden; }
.modal.open { display: flex; }
.modal .panel { background:#fff; border-radius: var(--radius-md); padding: 16px; box-shadow: 0 18px 50px rgba(0,0,0,0.25); width: min(960px, 100%); }
.modal .panel { background:#fff; border-radius: var(--radius-md); padding: 16px; box-shadow: 0 18px 50px rgba(0,0,0,0.25); width: min(960px, 100%); max-height: calc(100vh - 32px); overflow: auto; overscroll-behavior: contain; }
.modal .panel--avatar { width: min(1240px, 100%); height: calc(100vh - 32px); max-height: calc(100vh - 32px); padding: 12px; overflow: hidden; display: grid; grid-template-rows: auto minmax(0, 1fr); }
.dash-grid { display:grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 16px; }
.dash-grid-2 { display:grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 16px; }

View File

@@ -5,6 +5,9 @@ document.addEventListener('DOMContentLoaded', () => {
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) => {
@@ -41,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');
@@ -78,8 +94,13 @@ document.addEventListener('DOMContentLoaded', () => {
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);
@@ -88,6 +109,127 @@ document.addEventListener('DOMContentLoaded', () => {
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`;
@@ -142,7 +284,7 @@ document.addEventListener('DOMContentLoaded', () => {
} catch (err) {
// ignore
}
document.cookie = 'pkt_user_location=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax';
deleteCookie('pkt_user_location');
if (el.quickLat) el.quickLat.value = '';
if (el.quickLng) el.quickLng.value = '';
if (el.quickLoc) el.quickLoc.value = '';
@@ -465,6 +607,10 @@ document.addEventListener('DOMContentLoaded', () => {
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') {
@@ -511,6 +657,9 @@ 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;
@@ -554,6 +703,384 @@ 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'); });
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);
const avatarStyleConfigs = {
lorelei: {
fields: ['eyesVariant', 'eyebrowsVariant', 'mouthVariant', 'glassesVariant', 'hairVariant', 'beardVariant', 'earringsVariant'],
colorFields: ['earringsColor', 'eyebrowsColor', 'eyesColor', 'frecklesColor', 'glassesColor', 'hairColor', 'hairAccessoriesColor', 'mouthColor', 'noseColor', 'skinColor', 'backgroundColor'],
paramMap: {
eyesVariant: 'eyesVariant',
eyebrowsVariant: 'eyebrowsVariant',
mouthVariant: 'mouthVariant',
glassesVariant: 'glassesVariant',
hairVariant: 'hairVariant',
beardVariant: 'beardVariant',
earringsVariant: 'earringsVariant',
},
optionalProbabilityMap: {
glassesVariant: 'glassesProbability',
beardVariant: 'beardProbability',
earringsVariant: 'earringsProbability',
},
alwaysProbabilityMap: {
hairVariant: 'hairProbability',
},
},
croodles: {
fields: ['headVariant', 'eyesVariant', 'noseVariant', 'mouthVariant', 'topVariant', 'beardVariant', 'mustacheVariant'],
colorFields: ['baseColor', 'eyepatchColor', 'glassesColor', 'topColor', 'backgroundColor'],
paramMap: {
headVariant: 'headVariant',
eyesVariant: 'eyesVariant',
noseVariant: 'noseVariant',
mouthVariant: 'mouthVariant',
topVariant: 'topVariant',
beardVariant: 'beardVariant',
mustacheVariant: 'mustacheVariant',
},
optionalProbabilityMap: {
headVariant: 'headProbability',
eyesVariant: 'eyesProbability',
noseVariant: 'noseProbability',
mouthVariant: 'mouthProbability',
topVariant: 'topProbability',
beardVariant: 'beardProbability',
mustacheVariant: 'mustacheProbability',
},
alwaysProbabilityMap: {},
},
};
const getCurrentAvatarStyle = (form) => {
const input = form.querySelector('[data-avatar-style-select]');
const value = input?.value || '';
if (value) return value;
return form.querySelector('[data-avatar-style-panel]')?.getAttribute('data-avatar-style-panel') || 'lorelei';
};
const getAvatarSeedInput = (form) => form.querySelector('[data-avatar-seed]');
const getAvatarStylePanel = (form, style) => form.querySelector(`[data-avatar-style-panel="${style}"]`);
const readAvatarValue = (form, style, field) => {
const panel = getAvatarStylePanel(form, style);
if (!panel) return '';
const checked = panel.querySelector(`[data-avatar-field="${field}"]:checked`);
if (checked) return checked.value;
const input = panel.querySelector(`[data-avatar-field="${field}"]`);
return input ? input.value : '';
};
const readAvatarColorValue = (form, style, field) => {
const panel = getAvatarStylePanel(form, style);
if (!panel) return '';
const input = panel.querySelector(`[data-avatar-color-input="${field}"]`);
return input ? input.value : '';
};
const getAvatarStyleConfig = (style) => {
if (avatarStyleConfigs[style]) return avatarStyleConfigs[style];
return avatarStyleConfigs.lorelei;
};
const avatarEndpoint = '/api/avatar.php';
const buildAvatarParams = (form, style = getCurrentAvatarStyle(form), overrides = {}) => {
const styleConfig = getAvatarStyleConfig(style);
const seed = overrides.avatar_seed || getAvatarSeedInput(form)?.value || '';
if (!seed) return null;
const params = new URLSearchParams({ style, seed });
styleConfig.fields.forEach((field) => {
const value = Object.prototype.hasOwnProperty.call(overrides, field)
? overrides[field]
: readAvatarValue(form, style, field);
const param = styleConfig.paramMap[field];
const probabilityParam = styleConfig.optionalProbabilityMap[field];
const alwaysProbabilityParam = styleConfig.alwaysProbabilityMap[field];
if (alwaysProbabilityParam) {
params.set(alwaysProbabilityParam, value ? '100' : '0');
}
if (probabilityParam) {
params.set(probabilityParam, value ? '100' : '0');
}
if (value && param) params.set(param, value);
});
(styleConfig.colorFields || []).forEach((field) => {
const value = Object.prototype.hasOwnProperty.call(overrides, field)
? overrides[field]
: readAvatarColorValue(form, style, field);
if (value) {
params.set(field, value);
}
});
return params;
};
const updateAvatarPreview = (form) => {
const preview = form.querySelector('[data-avatar-preview] .pkt-avatar');
if (!preview) return;
const image = preview.querySelector('.pkt-avatar__img');
if (!image) return;
const style = getCurrentAvatarStyle(form);
const params = buildAvatarParams(form, style);
if (!params) return;
const url = `${avatarEndpoint}?${params.toString()}`;
if (!url) return;
image.src = url;
};
const getActiveAvatarField = (form, style) => {
const panel = getAvatarStylePanel(form, style);
if (!panel) return '';
const activeTab = panel.querySelector('[data-avatar-component-tab].is-active');
return activeTab?.getAttribute('data-avatar-component-tab') || panel.querySelector('[data-avatar-component-tab]')?.getAttribute('data-avatar-component-tab') || '';
};
const updateAvatarOptionPreviews = (form, style, activeField = null) => {
const panel = getAvatarStylePanel(form, style);
if (!panel) return;
const selector = activeField
? `[data-avatar-component-panel="${activeField}"] [data-avatar-option-thumb]`
: '[data-avatar-option-thumb]';
panel.querySelectorAll(selector).forEach((image) => {
const field = image.getAttribute('data-avatar-option-field') || '';
const value = image.getAttribute('data-avatar-option-value') || '';
const params = buildAvatarParams(form, style, { [field]: value });
if (!params) return;
const src = `${avatarEndpoint}?${params.toString()}`;
if (image.dataset.avatarLoadedSrc === src) return;
image.dataset.avatarLoadedSrc = src;
image.src = src;
});
};
const ensureAvatarBuilderLoaded = (form, style = getCurrentAvatarStyle(form)) => {
const loadedStyles = new Set((form.dataset.avatarInitializedStyles || '').split(',').filter(Boolean));
if (loadedStyles.has(style)) return;
loadedStyles.add(style);
form.dataset.avatarInitializedStyles = Array.from(loadedStyles).join(',');
updateAvatarPreview(form);
updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
};
document.querySelectorAll('[data-avatar-builder]').forEach((form) => {
form.querySelectorAll('[data-avatar-field]').forEach((field) => {
field.addEventListener('change', () => {
const style = field.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
ensureAvatarBuilderLoaded(form, style);
updateAvatarPreview(form);
updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
});
field.addEventListener('input', () => {
const style = field.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
ensureAvatarBuilderLoaded(form, style);
updateAvatarPreview(form);
updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
});
});
form.querySelectorAll('[data-avatar-color-picker]').forEach((picker) => {
const syncColor = () => {
const style = picker.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
const field = picker.getAttribute('data-avatar-color-picker') || '';
const panel = getAvatarStylePanel(form, style);
if (!panel || !field) return;
const hidden = panel.querySelector(`[data-avatar-color-input="${field}"]`);
if (hidden) {
hidden.value = picker.value;
}
ensureAvatarBuilderLoaded(form, style);
updateAvatarPreview(form);
updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
};
picker.addEventListener('input', syncColor);
picker.addEventListener('change', syncColor);
});
form.querySelectorAll('[data-avatar-color-reset]').forEach((button) => {
button.addEventListener('click', () => {
const style = button.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
const field = button.getAttribute('data-avatar-color-reset') || '';
const panel = getAvatarStylePanel(form, style);
if (!panel || !field) return;
const hidden = panel.querySelector(`[data-avatar-color-input="${field}"]`);
const picker = panel.querySelector(`[data-avatar-color-picker="${field}"]`);
const defaultValue = picker?.getAttribute('data-avatar-color-default') || '#000000';
if (hidden) {
hidden.value = '';
}
if (picker) {
picker.value = defaultValue;
}
ensureAvatarBuilderLoaded(form, style);
updateAvatarPreview(form);
updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
});
});
form.querySelectorAll('[data-avatar-color-sample-field]').forEach((button) => {
button.addEventListener('click', () => {
const style = button.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
const field = button.getAttribute('data-avatar-color-sample-field') || '';
const value = button.getAttribute('data-avatar-color-sample-value') || '';
const panel = getAvatarStylePanel(form, style);
if (!panel || !field || !value) return;
const hidden = panel.querySelector(`[data-avatar-color-input="${field}"]`);
const picker = panel.querySelector(`[data-avatar-color-picker="${field}"]`);
if (hidden) hidden.value = value;
if (picker) picker.value = value;
ensureAvatarBuilderLoaded(form, style);
updateAvatarPreview(form);
updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
});
});
const setActiveComponentPanel = (style, field) => {
const panel = getAvatarStylePanel(form, style);
if (!panel) return;
panel.querySelectorAll('[data-avatar-component-tab]').forEach((button) => {
const active = button.getAttribute('data-avatar-component-tab') === field;
button.classList.toggle('is-active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
});
panel.querySelectorAll('[data-avatar-component-panel]').forEach((componentPanel) => {
componentPanel.classList.toggle('is-active', componentPanel.getAttribute('data-avatar-component-panel') === field);
});
updateAvatarOptionPreviews(form, style, field);
};
const setActiveStyle = (style) => {
form.querySelectorAll('[data-avatar-style-panel]').forEach((panel) => {
const active = panel.getAttribute('data-avatar-style-panel') === style;
panel.hidden = !active;
panel.classList.toggle('is-active', active);
panel.querySelectorAll('[data-avatar-field]').forEach((input) => {
input.disabled = !active;
});
panel.querySelectorAll('[data-avatar-color-input], [data-avatar-color-picker], [data-avatar-color-reset], [data-avatar-color-sample-field]').forEach((input) => {
input.disabled = !active;
});
});
ensureAvatarBuilderLoaded(form, style);
setActiveComponentPanel(style, getActiveAvatarField(form, style));
updateAvatarPreview(form);
};
form.querySelector('[data-avatar-style-select]')?.addEventListener('change', (event) => {
const nextStyle = event.currentTarget?.value || getCurrentAvatarStyle(form);
setActiveStyle(nextStyle);
});
form.querySelectorAll('[data-avatar-component-tab]').forEach((button) => {
button.addEventListener('click', () => {
const style = button.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
const field = button.getAttribute('data-avatar-component-tab');
if (field) {
ensureAvatarBuilderLoaded(form, style);
setActiveComponentPanel(style, field);
}
});
});
Object.values(avatarStyleConfigs).forEach((styleConfig) => {
styleConfig.fields.forEach((fieldName) => {
form.querySelectorAll(`[data-avatar-field="${fieldName}"]`).forEach((input) => {
input.addEventListener('change', () => {
const style = input.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
setActiveComponentPanel(style, fieldName);
});
});
});
});
const avatarPrefixes = [
'abend', 'ahorn', 'anker', 'atlas', 'berg', 'blick', 'brise', 'echo',
'eiche', 'fjord', 'fluss', 'fokus', 'hafen', 'herz', 'horizont', 'insel',
'kiesel', 'kompass', 'kraft', 'linie', 'licht', 'lotse', 'mond', 'morgen',
'nord', 'pfad', 'quelle', 'rauch', 'runde', 'sand', 'sommer', 'stein',
'sturm', 'tal', 'ufer', 'wald', 'welle', 'wind', 'winkel', 'zeit',
];
const avatarSuffixes = [
'alpha', 'atlas', 'balu', 'bravo', 'caspar', 'dario', 'emil', 'felix',
'fiete', 'finn', 'gerrit', 'hanno', 'ilan', 'joris', 'kian', 'leon',
'linus', 'maik', 'malo', 'marlon', 'mats', 'mika', 'milan', 'noel',
'ole', 'oskar', 'pepe', 'quinn', 'remo', 'sam', 'taro', 'timo',
'veit', 'vito', 'yaro', 'yuri', 'zeno', 'zuri',
];
const slugifySeed = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'papa-kind-treff';
const buildSeed = () => {
const prefix = avatarPrefixes[Math.floor(Math.random() * avatarPrefixes.length)];
const suffix = avatarSuffixes[Math.floor(Math.random() * avatarSuffixes.length)];
const tail = Math.floor(Math.random() * 997);
return slugifySeed(`${prefix}-${suffix}-${tail}`);
};
form.querySelector('[data-avatar-random]')?.addEventListener('click', () => {
const style = getCurrentAvatarStyle(form);
const styleConfig = getAvatarStyleConfig(style);
const seedField = getAvatarSeedInput(form);
if (!(seedField instanceof HTMLInputElement)) return;
seedField.value = buildSeed();
styleConfig.fields.forEach((field) => {
const panel = getAvatarStylePanel(form, style);
if (!panel) return;
const options = Array.from(panel.querySelectorAll(`[data-avatar-field="${field}"]`));
if (!options.length) return;
const randomOption = options[Math.floor(Math.random() * options.length)];
if (!(randomOption instanceof HTMLInputElement)) return;
randomOption.checked = true;
});
ensureAvatarBuilderLoaded(form, style);
updateAvatarPreview(form);
updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
});
setActiveStyle(getCurrentAvatarStyle(form));
});
document.querySelectorAll('[data-modal-open="modalAvatar"]').forEach((button) => {
button.addEventListener('click', () => {
const form = document.querySelector('#modalAvatar [data-avatar-builder]');
if (!form) return;
const style = getCurrentAvatarStyle(form);
window.setTimeout(() => ensureAvatarBuilderLoaded(form, style), 20);
});
});
document.querySelectorAll('[data-nav-events]').forEach(link => {
link.addEventListener('click', async () => {
@@ -563,6 +1090,12 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
consentState = readConsent();
if (consentState?.analytics) {
loadMatomo();
}
showConsentBannerIfNeeded();
try {
if (!isLoggedIn) {
const guestPreference = window.localStorage.getItem('pkt_location_preference_guest');
@@ -581,7 +1114,7 @@ document.addEventListener('DOMContentLoaded', () => {
renderSlider(initialLocation);
renderThreadSlider();
if (effectiveLocationPreference === 'enabled' && window.location.pathname === '/') {
if (effectiveLocationPreference === 'enabled' && window.location.pathname === '/' && consentState?.external_services) {
ensureEventsLocation();
} else if (window.location.hash === '#events' || window.location.hash === '#quicksearch') {
ensureEventsLocation();

View File

@@ -71,6 +71,9 @@ if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/retool
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/api'))) {
$skipLayout = true;
}
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/calendar'))) {
$skipLayout = true;
}
// ------------------------------------
// Ausgabe

View File

@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require dirname(__DIR__, 2) . '/api/avatar.php';

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\App;
use App\CalendarSync;
$app = App::get();
if (!isset($_SESSION['user_id'])) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo 'Login erforderlich.';
return;
}
$calendarSync = new CalendarSync($app);
$calendarSync->ensureSchema();
$userId = (int)$_SESSION['user_id'];
$ics = $calendarSync->renderUserCalendarIcs($userId, 'Papa-Kind-Treff Events');
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename="papa-kind-treff-events.ics"');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
echo $ics;

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\App;
use App\CalendarSync;
$app = App::get();
$token = trim((string)($_GET['token'] ?? ''));
if ($token === '') {
http_response_code(400);
header('Content-Type: text/plain; charset=utf-8');
echo 'Token fehlt.';
return;
}
$calendarSync = new CalendarSync($app);
$calendarSync->ensureSchema();
$userId = $calendarSync->findUserIdByFeedToken($token);
if (!$userId) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'Kalender-Feed nicht gefunden.';
return;
}
$calendarSync->touchFeedAccess($userId);
$ics = $calendarSync->renderUserCalendarIcs($userId, 'Papa-Kind-Treff Events');
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: inline; filename="papa-kind-treff-events-feed.ics"');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
echo $ics;

View File

@@ -1,4 +1,4 @@
<?php
declare(strict_types=1);
tpl('community-admin', 'landing', 'account');
redirect('/dashboard?area=admin&section=community-moderation');

238
public/page/datenschutz.php Normal file
View File

@@ -0,0 +1,238 @@
<?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: 5. August 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>
<div class="note" style="margin:18px 0 22px;">
<strong>Wichtiger Hinweis zum Datenschutz:</strong>
Personenbezogene und sensible Profilangaben werden bei Papa-Kind-Treff app-seitig verschlüsselt gespeichert.
Das betrifft insbesondere auch die Konto-E-Mail sowie Profilangaben wie Vorname, Nachname, Straße/Hausnummer,
Telefonnummer, Beruf, Sprachen, Kurzvorstellung und einzelne Kinderdaten. Für Login, Verifizierung und
Passwort-Reset wird die E-Mail technisch nur über einen separaten HMAC-Lookup-Hash adressiert.
</div>
<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, Adresse, Ort, Sprachen, Kurzbeschreibung, optionale Kinderangaben,
eigene Events, Orte, Veranstaltungen, hochgeladene Eintragsbilder, Event-Teilnahmen und Community-Inhalte gehören.
</p>
<p>
Soweit es sich um sensible oder besonders persönliche Profildaten handelt, werden diese innerhalb der
Anwendung verschlüsselt gespeichert und verarbeitet.
</p>
<p>
Wenn du deine Events in einen privaten Kalender exportierst oder einen persönlichen Kalender-Feed abonnierst,
werden dabei ausschließlich deine eigenen Events und deine Event-Teilnahmen als Kalenderdaten bereitgestellt.
Der abonnierbare Feed ist über eine persönliche, nicht öffentliche URL abgesichert. Diese URL sollte vertraulich
behandelt und nicht an Dritte weitergegeben werden.
</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 Events 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 sensible Profildaten wie Vorname, Nachname, Telefonnummer, Beruf, Sprachen,
Kurzvorstellung sowie einzelne Kinderdaten, werden nicht nur organisatorisch, sondern zusätzlich verschlüsselt verarbeitet.
Das dient dem Schutz besonders sensibler Angaben innerhalb der Plattform.
</p>
<p>
Die verschlüsselte Speicherung ist damit ein grundsätzlicher Bestandteil des Mitgliederbereichs und nicht nur
eine optionale Zusatzmaßnahme.
</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 Events, Termine und Treffen in deiner Nähe angezeigt werden können.
</p>
<p>
Zusätzlich kannst du im Mitgliederbereich deine Profiladresse sowie Adressen für eigene Events, Orte und
Veranstaltungen über die gleichen externen Adressdienste lokalisieren lassen. Dabei kannst du entweder nach
passenden Adress-Treffern suchen oder die vom Browser ermittelte Position in eine Adresse übernehmen.
In beiden Fällen werden die von dir eingegebenen oder vom Browser bereitgestellten Positionsdaten
an den jeweiligen Geocoding-Dienst übermittelt.
</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. Profilbilder mit DiceBear</h2>
<p>
Profilbilder werden serverseitig über unsere Anwendung erzeugt. Dafür nutzt Papa-Kind-Treff aktuell DiceBear
als technische Quelle für freigeschaltete Avatar-Stile. Gespeichert werden Stil, Seed und die im Generator
ausgewählten Varianten im Profil. Das fertige Avatar-SVG wird zusätzlich lokal auf unserem Server abgelegt.
Dein Browser lädt das Profilbild daher nicht direkt bei DiceBear.
</p>
<p>
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, soweit die Avatar-Erzeugung Teil des gewählten
Mitgliederprofils ist, sowie ergänzend Art. 6 Abs. 1 lit. f DSGVO für eine konsistente Darstellung der Plattform.
</p>
</section>
<section>
<h2>11. 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, DiceBear für die serverseitige Avatar-Erzeugung sowie die oben genannten externen Karten- und Standortdienste gehören.
</p>
</section>
<section>
<h2>12. 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>13. 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>14. 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>

View File

@@ -10,12 +10,16 @@ $app = app();
$pdo = $app->pdo();
$users = [];
$error = '';
$emailStore = null;
try {
if (!$pdo) {
throw new RuntimeException('Keine Datenbankverbindung verfügbar.');
}
$emailStore = new App\UserEmailStore($pdo);
$emailStore->ensureSchema();
$hasRolesTable = false;
try {
$stmt = $pdo->query("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'user_roles' LIMIT 1");
@@ -36,6 +40,7 @@ try {
';
$stmt = $pdo->query($sql);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
$users = $emailStore->decryptRowEmails($users);
if ($hasRolesTable && $users) {
$roleStmt = $pdo->query('SELECT user_id, role FROM user_roles ORDER BY user_id ASC, role ASC');

View File

@@ -23,7 +23,7 @@ declare(strict_types=1);
</p>
<p>
Die Idee zu dieser Seite ist deshalb nicht nur entstanden, um Termine und Treffen sichtbar zu machen, sondern auch,
Die Idee zu dieser Seite ist deshalb nicht nur entstanden, um Events, 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,

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,8 @@ namespace App;
final class Auth
{
private ?UserEmailStore $emailStore = null;
public function __construct(private App $app) {}
private function pdo(): \PDO
@@ -16,6 +18,16 @@ final class Auth
return $pdo;
}
private function emailStore(): UserEmailStore
{
if ($this->emailStore === null) {
$this->emailStore = new UserEmailStore($this->pdo());
$this->emailStore->ensureSchema();
}
return $this->emailStore;
}
public function register(string $displayName, string $email, string $password): int
{
$pdo = $this->pdo();
@@ -26,18 +38,18 @@ final class Auth
throw new \InvalidArgumentException('Display-Name, E-Mail und Passwort sind erforderlich.');
}
$emailStore = $this->emailStore();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
if ($stmt->fetchColumn()) {
if ($emailStore->findUserByEmail($email, 'id')) {
throw new \RuntimeException('E-Mail ist bereits registriert.');
}
$hash = password_hash($password, PASSWORD_ARGON2ID);
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash, status, created_at, updated_at) VALUES (:email, :pw, :status, NOW(), NOW())');
$stmt = $pdo->prepare('INSERT INTO users (email, email_lookup_hash, password_hash, status, created_at, updated_at) VALUES (:email, :lookup, :pw, :status, NOW(), NOW())');
$stmt->execute([
'email' => $email,
'email' => $emailStore->encrypt($email),
'lookup' => $emailStore->lookupHash($email),
'pw' => $hash,
'status' => 'pending',
]);
@@ -80,11 +92,12 @@ final class Auth
public function verifyCode(string $email, string $code): int
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$email = $this->emailStore()->normalize($email);
$hash = hash('sha256', $code);
$stmt = $pdo->prepare('SELECT u.id, u.status, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
$stmt->execute(['type' => 'verify', 'email' => $email]);
$lookupHash = $this->emailStore()->lookupHash($email);
$stmt = $pdo->prepare('SELECT u.id, u.status, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email_lookup_hash = :lookup AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
$stmt->execute(['type' => 'verify', 'lookup' => $lookupHash]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
@@ -109,17 +122,17 @@ final class Auth
public function createResetCode(string $email): array
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$email = $this->emailStore()->normalize($email);
$stmt = $pdo->prepare('SELECT u.id, p.display_name FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
$row = $this->emailStore()->findUserByEmail($email, 'id');
if (!$row) {
throw new \RuntimeException('E-Mail ist nicht registriert.');
}
$userId = (int)$row['id'];
$displayName = (string)($row['display_name'] ?? $email);
$stmt = $pdo->prepare('SELECT display_name FROM user_profiles WHERE user_id = :id LIMIT 1');
$stmt->execute(['id' => $userId]);
$displayName = (string)($stmt->fetchColumn() ?: $email);
$code = $this->generateCode(6);
$hash = hash('sha256', $code);
@@ -138,11 +151,12 @@ final class Auth
public function verifyResetCode(string $email, string $code): int
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$email = $this->emailStore()->normalize($email);
$hash = hash('sha256', $code);
$stmt = $pdo->prepare('SELECT u.id, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
$stmt->execute(['type' => 'reset', 'email' => $email]);
$lookupHash = $this->emailStore()->lookupHash($email);
$stmt = $pdo->prepare('SELECT u.id, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email_lookup_hash = :lookup AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
$stmt->execute(['type' => 'reset', 'lookup' => $lookupHash]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
@@ -191,11 +205,9 @@ final class Auth
public function login(string $email, string $password): array
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$email = $this->emailStore()->normalize($email);
$stmt = $pdo->prepare('SELECT id, password_hash, status FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
$row = $this->emailStore()->findUserByEmail($email, 'id, password_hash, status');
if (!$row) {
throw new \RuntimeException('E-Mail oder Passwort ist falsch.');
@@ -214,4 +226,23 @@ final class Auth
return ['id' => $userId, 'status' => $status];
}
public function findUserMetaByEmail(string $email): ?array
{
$row = $this->emailStore()->findUserByEmail($email, 'id, status');
if (!$row) {
return null;
}
$stmt = $this->pdo()->prepare('SELECT display_name FROM user_profiles WHERE user_id = :id LIMIT 1');
$stmt->execute(['id' => (int)$row['id']]);
$row['display_name'] = (string)($stmt->fetchColumn() ?: '');
return $row;
}
public function getEmailByUserId(int $userId): string
{
return $this->emailStore()->getEmailByUserId($userId);
}
}

View File

@@ -0,0 +1,399 @@
<?php
declare(strict_types=1);
namespace App\Avatar;
final class AvatarManager
{
private static ?array $configCache = null;
public static function config(): array
{
if (self::$configCache !== null) {
return self::$configCache;
}
$path = dirname(__DIR__, 3) . '/config/avatar.php';
$config = file_exists($path) ? require $path : [];
if (!is_array($config)) {
$config = [];
}
return self::$configCache = $config;
}
public static function defaultStyle(): string
{
$config = self::config();
$styles = self::enabledStyles();
$default = (string)($config['default_style'] ?? 'lorelei');
return isset($styles[$default]) ? $default : (array_key_first($styles) ?: 'lorelei');
}
public static function enabledStyles(): array
{
$config = self::config();
$styles = $config['styles'] ?? [];
$enabled = $config['enabled_styles'] ?? [];
$result = [];
foreach ($enabled as $key) {
if (!is_string($key) || !isset($styles[$key]['class'])) {
continue;
}
$class = (string)$styles[$key]['class'];
if (!class_exists($class)) {
continue;
}
$result[$key] = [
'key' => $key,
'label' => (string)($styles[$key]['label'] ?? $class::label()),
'class' => $class,
'fixed_colors' => is_array($styles[$key]['fixed_colors'] ?? null) ? $styles[$key]['fixed_colors'] : [],
'color_groups' => is_array($styles[$key]['color_groups'] ?? null) ? $styles[$key]['color_groups'] : [],
'color_controls' => is_array($styles[$key]['color_controls'] ?? null) ? $styles[$key]['color_controls'] : [],
];
}
if ($result === []) {
$result['lorelei'] = [
'key' => 'lorelei',
'label' => Lorelei::label(),
'class' => Lorelei::class,
'fixed_colors' => [],
'color_groups' => [],
'color_controls' => [],
];
$result['croodles'] = [
'key' => 'croodles',
'label' => Croodles::label(),
'class' => Croodles::class,
'fixed_colors' => [],
'color_groups' => [],
'color_controls' => [],
];
}
return $result;
}
public static function styleMeta(string $style): array
{
$styles = self::enabledStyles();
return $styles[$style] ?? $styles[self::defaultStyle()];
}
public static function styleClass(string $style): string
{
return (string)self::styleMeta($style)['class'];
}
public static function genericColumns(): array
{
return [
'avatar_style',
'avatar_seed',
'avatar_config_json',
];
}
public static function legacyColumns(): array
{
return [
'avatar_preset',
'avatar_lorelei_eyes_variant',
'avatar_lorelei_eyebrows_variant',
'avatar_lorelei_mouth_variant',
'avatar_lorelei_glasses_variant',
'avatar_lorelei_hair_variant',
'avatar_lorelei_beard_variant',
'avatar_lorelei_earrings_variant',
];
}
public static function allProfileColumns(): array
{
return array_merge(self::genericColumns(), self::legacyColumns());
}
public static function normalizeProfile(array $profile, ?int $userId = null): array
{
$profile['avatar_style'] = (string)($profile['avatar_style'] ?? '');
$profile['avatar_seed'] = (string)($profile['avatar_seed'] ?? '');
$profile['avatar_config_json'] = (string)($profile['avatar_config_json'] ?? '');
$style = $profile['avatar_style'] !== '' ? $profile['avatar_style'] : self::defaultStyle();
$styleMeta = self::styleMeta($style);
$style = $styleMeta['key'];
$styleClass = (string)$styleMeta['class'];
$seed = $styleClass::normalizeSeed(
$profile['avatar_seed'] !== ''
? $profile['avatar_seed']
: (string)($profile['avatar_preset'] ?? $styleClass::defaultSeed())
);
$decoded = json_decode($profile['avatar_config_json'], true);
$config = is_array($decoded) ? $decoded : [];
if ($config === [] && $style === Lorelei::key()) {
$config = Lorelei::legacyConfig($profile);
}
$config = $styleClass::normalizeConfig($config);
$profile['avatar_style'] = $style;
$profile['avatar_seed'] = $seed;
$profile['avatar_config_array'] = $config;
$profile['avatar_config_json'] = json_encode($config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}';
$profile['avatar_builder_styles'] = self::builderStyles($profile);
if ($style === Lorelei::key()) {
foreach (Lorelei::legacyColumnsFromConfig($seed, $config) as $column => $value) {
$profile[$column] = $value;
}
}
$profile['avatar_image_url'] = self::buildProxyUrl($style, $seed, $config);
if ($userId !== null && $userId > 0) {
self::ensureUserAvatarFile($userId, $profile);
$profile['avatar_image_url'] = self::userAvatarPublicUrl($userId);
}
return $profile;
}
public static function normalizeSubmission(array $input, ?int $userId = null): array
{
$style = (string)($input['avatar_style'] ?? self::defaultStyle());
$styleMeta = self::styleMeta($style);
$style = $styleMeta['key'];
$styleClass = (string)$styleMeta['class'];
$seed = $styleClass::normalizeSeed((string)($input['avatar_seed'] ?? ''));
$configInput = [];
if (isset($input['avatar_config']) && is_array($input['avatar_config'])) {
$raw = $input['avatar_config'];
if (isset($raw[$style]) && is_array($raw[$style])) {
$configInput = $raw[$style];
} else {
$configInput = $raw;
}
}
$config = $styleClass::normalizeConfig($configInput);
$normalized = [
'avatar_style' => $style,
'avatar_seed' => $seed,
'avatar_config_array' => $config,
'avatar_config_json' => json_encode($config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}',
];
if ($style === Lorelei::key()) {
$normalized += Lorelei::legacyColumnsFromConfig($seed, $config);
}
if ($userId !== null && $userId > 0) {
self::ensureUserAvatarFile($userId, $normalized, true);
}
return $normalized;
}
public static function builderStyles(array $profile = []): array
{
$styles = [];
$currentStyle = (string)($profile['avatar_style'] ?? self::defaultStyle());
$currentConfig = is_array($profile['avatar_config_array'] ?? null) ? $profile['avatar_config_array'] : [];
foreach (self::enabledStyles() as $meta) {
$styleClass = (string)$meta['class'];
$styleConfig = $currentStyle === $meta['key'] ? $currentConfig : $styleClass::defaultConfig();
$components = [];
$colorDefinitions = $styleClass::colorDefinitions();
$colorControls = is_array($meta['color_controls'] ?? null) ? $meta['color_controls'] : [];
$colorGroups = is_array($meta['color_groups'] ?? null) ? $meta['color_groups'] : [];
foreach ($styleClass::componentDefinitions() as $field => $definition) {
$options = [];
foreach ($styleClass::options()[$field] ?? [] as $value => $label) {
$value = (string)$value;
$options[] = [
'value' => $value,
'label' => (string)$label,
'checked' => (string)($styleConfig[$field] ?? '') === $value,
];
}
$componentColors = [];
foreach (($colorGroups[$field] ?? []) as $colorField) {
if (!isset($colorDefinitions[$colorField])) {
continue;
}
$componentColors[$colorField] = [
'field' => $colorField,
'label' => (string)($colorControls[$colorField]['label'] ?? $colorDefinitions[$colorField]['label'] ?? $colorField),
'value' => (string)($styleConfig[$colorField] ?? ''),
'default' => (string)($colorControls[$colorField]['default'] ?? $colorDefinitions[$colorField]['default'] ?? '#c4a484'),
'samples' => is_array($colorControls[$colorField]['samples'] ?? null) ? $colorControls[$colorField]['samples'] : [],
'allow_custom' => (bool)($colorControls[$colorField]['allow_custom'] ?? true),
];
}
$components[$field] = [
'field' => $field,
'label' => (string)($definition['label'] ?? $field),
'count' => count(array_filter($options, static fn(array $option): bool => $option['value'] !== '')),
'options' => $options,
'colors' => $componentColors,
];
}
$styles[$meta['key']] = [
'key' => $meta['key'],
'label' => $meta['label'],
'components' => $components,
];
}
return $styles;
}
public static function buildProxyUrl(string $style, string $seed, array $config): string
{
$styleMeta = self::styleMeta($style);
$styleClass = (string)$styleMeta['class'];
$query = $styleClass::buildQuery($seed, $config);
$query['style'] = $styleMeta['key'];
return '/api/avatar.php?' . http_build_query($query);
}
public static function userAvatarFileName(int $userId): string
{
return 'ptk-' . max(1, $userId) . '.svg';
}
public static function userAvatarAbsolutePath(int $userId): string
{
$storage = self::config()['storage'] ?? [];
return rtrim((string)($storage['user_dir'] ?? dirname(__DIR__, 3) . '/public/assets/avatars/users'), '/') . '/' . self::userAvatarFileName($userId);
}
public static function userAvatarPublicUrl(int $userId): string
{
$storage = self::config()['storage'] ?? [];
$base = rtrim((string)($storage['user_public_path'] ?? '/assets/avatars/users'), '/');
$path = self::userAvatarAbsolutePath($userId);
$version = is_file($path) ? '?v=' . (string)filemtime($path) : '';
return $base . '/' . self::userAvatarFileName($userId) . $version;
}
public static function ensureUserAvatarFile(int $userId, array $profile, bool $force = false): bool
{
if ($userId <= 0) {
return false;
}
$storage = self::config()['storage'] ?? [];
$userDir = (string)($storage['user_dir'] ?? dirname(__DIR__, 3) . '/public/assets/avatars/users');
if (!is_dir($userDir) && !@mkdir($userDir, 0775, true) && !is_dir($userDir)) {
return false;
}
$targetFile = self::userAvatarAbsolutePath($userId);
if (!$force && is_file($targetFile) && filesize($targetFile) > 0) {
return true;
}
$style = (string)($profile['avatar_style'] ?? self::defaultStyle());
$styleMeta = self::styleMeta($style);
$styleClass = (string)$styleMeta['class'];
$seed = $styleClass::normalizeSeed((string)($profile['avatar_seed'] ?? ''));
$config = $styleClass::normalizeConfig((array)($profile['avatar_config_array'] ?? []));
$svg = self::fetchSvg($style, $seed, $config);
if ($svg === '') {
$svg = $styleClass::fallbackSvg($seed, 'Avatar');
}
return @file_put_contents($targetFile, $svg) !== false;
}
public static function fetchSvg(string $style, string $seed, array $config): string
{
$styleMeta = self::styleMeta($style);
$styleClass = (string)$styleMeta['class'];
$query = $styleClass::buildQuery($seed, $config);
$query['style'] = $styleMeta['key'];
return self::fetchSvgFromQuery($query);
}
public static function fetchSvgFromQuery(array $input): string
{
$style = (string)($input['style'] ?? self::defaultStyle());
$styleMeta = self::styleMeta($style);
$style = $styleMeta['key'];
$styleClass = (string)$styleMeta['class'];
$seed = $styleClass::normalizeSeed((string)($input['seed'] ?? $styleClass::defaultSeed()));
unset($input['style'], $input['seed']);
$config = $styleClass::queryToConfig($input);
$query = $styleClass::buildQuery($seed, $config);
$cacheDir = rtrim((string)((self::config()['storage']['cache_dir'] ?? dirname(__DIR__, 3) . '/public/assets/avatars/dicebear-cache')), '/') . '/' . $style;
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0775, true);
}
$cacheKey = sha1(json_encode([$style, $query], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: $seed);
$cacheFile = $cacheDir . '/' . $cacheKey . '.svg';
$svg = is_file($cacheFile) ? file_get_contents($cacheFile) : false;
if (is_string($svg) && $svg !== '') {
return $svg;
}
$dicebear = self::config()['dicebear'] ?? [];
$baseUrl = rtrim((string)($dicebear['base_url'] ?? 'https://api.dicebear.com'), '/');
$version = trim((string)($dicebear['version'] ?? '10.x'), '/');
$url = $baseUrl . '/' . $version . '/' . rawurlencode($style) . '/svg?' . http_build_query($query + ['size' => '256']);
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => max(1, (int)($dicebear['timeout'] ?? 2)),
'header' => "User-Agent: " . (string)($dicebear['user_agent'] ?? 'Papa-Kind-Treff/1.0') . "\r\nAccept: image/svg+xml\r\n",
],
]);
$remote = @file_get_contents($url, false, $context);
if (is_string($remote) && str_contains($remote, '<svg')) {
@file_put_contents($cacheFile, $remote);
return $remote;
}
return $styleClass::fallbackSvg($seed, 'Avatar');
}
public static function render(array $profile, string $name = 'Mitglied', string $size = 'md', ?int $userId = null): string
{
$normalized = self::normalizeProfile($profile, $userId);
$src = (string)($normalized['avatar_image_url'] ?? self::buildProxyUrl(self::defaultStyle(), Lorelei::defaultSeed(), Lorelei::defaultConfig()));
$sizeClass = preg_replace('/[^a-z0-9\-]/', '', strtolower($size));
return sprintf(
'<span class="%s" role="img" aria-label="%s"><img class="pkt-avatar__img" src="%s" alt=""></span>',
htmlspecialchars(implode(' ', [
'pkt-avatar',
'pkt-avatar--' . ($sizeClass !== '' ? $sizeClass : 'md'),
'pkt-avatar--generated',
]), ENT_QUOTES),
htmlspecialchars('Avatar von ' . $name, ENT_QUOTES),
htmlspecialchars($src, ENT_QUOTES)
);
}
}

259
src/App/Avatar/Croodles.php Normal file
View File

@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace App\Avatar;
final class Croodles
{
public static function key(): string
{
return 'croodles';
}
public static function label(): string
{
return 'Croodles';
}
public static function defaultSeed(): string
{
return 'papa-kind-treff';
}
public static function defaultConfig(): array
{
return array_merge([
'beardVariant' => '',
'eyesVariant' => '',
'headVariant' => '',
'mouthVariant' => '',
'mustacheVariant' => '',
'noseVariant' => '',
'topVariant' => '',
'baseColor' => '#ffffff',
'eyepatchColor' => '',
'glassesColor' => '',
'topColor' => '',
'backgroundColor' => '',
], self::colorCompanionDefaults());
}
public static function options(): array
{
return [
'beardVariant' => ['' => 'Keine'] + self::variantOptions(5),
'eyesVariant' => ['' => 'Keine'] + self::variantOptions(16),
'headVariant' => ['' => 'Keine'] + self::variantOptions(8),
'mouthVariant' => ['' => 'Keine'] + self::variantOptions(18),
'mustacheVariant' => ['' => 'Keine'] + self::variantOptions(4),
'noseVariant' => ['' => 'Keine'] + self::variantOptions(9),
'topVariant' => ['' => 'Keine'] + self::variantOptions(29),
];
}
public static function componentDefinitions(): array
{
return [
'headVariant' => ['label' => 'Kopf'],
'eyesVariant' => ['label' => 'Augen'],
'noseVariant' => ['label' => 'Nase'],
'mouthVariant' => ['label' => 'Mund'],
'topVariant' => ['label' => 'Top'],
'beardVariant' => ['label' => 'Bart'],
'mustacheVariant' => ['label' => 'Schnurrbart'],
];
}
public static function colorDefinitions(): array
{
return [
'baseColor' => ['label' => 'Basisfarbe', 'default' => '#ffffff'],
'eyepatchColor' => ['label' => 'Augenklappe', 'default' => '#283341'],
'glassesColor' => ['label' => 'Brille', 'default' => '#283341'],
'topColor' => ['label' => 'Top-Farbe', 'default' => '#4f6ea8'],
'backgroundColor' => ['label' => 'Hintergrund', 'default' => ''],
];
}
public static function normalizeSeed(string $seed): string
{
$seed = strtolower(trim($seed));
$seed = preg_replace('/[^a-z0-9]+/', '-', $seed) ?: '';
$seed = trim($seed, '-');
if ($seed === '') {
$seed = self::defaultSeed();
}
return substr($seed, 0, 100);
}
public static function normalizeConfig(array $input): array
{
$defaults = self::defaultConfig();
$normalized = [];
foreach ($defaults as $key => $defaultValue) {
if (array_key_exists($key, self::colorDefinitions())) {
$normalized[$key] = self::normalizeColor((string)($input[$key] ?? $defaultValue));
continue;
}
if (str_ends_with($key, 'Fill')) {
$normalized[$key] = self::normalizeFill((string)($input[$key] ?? $defaultValue));
continue;
}
if (str_ends_with($key, 'FillStops')) {
$normalized[$key] = self::normalizeFillStops($input[$key] ?? $defaultValue);
continue;
}
if (str_ends_with($key, 'Angle')) {
$normalized[$key] = self::normalizeAngle($input[$key] ?? $defaultValue);
continue;
}
$normalized[$key] = self::normalizeVariant((string)($input[$key] ?? $defaultValue));
}
return $normalized;
}
public static function buildQuery(string $seed, array $config): array
{
$config = self::normalizeConfig($config);
$query = ['seed' => self::normalizeSeed($seed)];
$componentFields = array_keys(self::componentDefinitions());
foreach ($componentFields as $field) {
$probability = preg_replace('/Variant$/', 'Probability', $field);
if ($config[$field] === '') {
$query[$probability] = '0';
continue;
}
$query[$field] = $config[$field];
$query[$probability] = '100';
}
foreach (array_keys(self::colorDefinitions()) as $field) {
if ($config[$field] !== '') {
$query[$field] = $config[$field];
foreach (self::colorCompanionFields($field) as $companionField) {
if ($config[$companionField] !== '') {
$query[$companionField] = $config[$companionField];
}
}
}
}
return $query;
}
public static function queryToConfig(array $query): array
{
$config = self::defaultConfig();
foreach (array_keys(self::componentDefinitions()) as $field) {
$config[$field] = self::normalizeVariant((string)($query[$field] ?? ''));
$probability = (int)($query[preg_replace('/Variant$/', 'Probability', $field)] ?? 100);
if ($probability <= 0) {
$config[$field] = '';
}
}
foreach (array_keys(self::colorDefinitions()) as $field) {
$config[$field] = self::normalizeColor((string)($query[$field] ?? ''));
foreach (self::colorCompanionFields($field) as $companionField) {
$config[$companionField] = (string)($query[$companionField] ?? '');
}
}
return self::normalizeConfig($config);
}
public static function fallbackSvg(string $seed, string $label = 'Avatar'): string
{
return Lorelei::fallbackSvg($seed, $label);
}
private static function normalizeVariant(string $value): string
{
$value = strtolower(trim($value));
if ($value === '') {
return '';
}
return preg_match('/^variant\d{2}$/', $value) === 1 ? $value : '';
}
private static function normalizeColor(string $value): string
{
$value = strtolower(trim($value));
if ($value === '') {
return '';
}
return preg_match('/^#[0-9a-f]{6}$/', $value) === 1 ? $value : '';
}
private static function normalizeFill(string $value): string
{
$value = strtolower(trim($value));
if ($value === '') {
return '';
}
return in_array($value, ['solid', 'linear', 'radial'], true) ? $value : '';
}
private static function normalizeFillStops(mixed $value): string
{
if ($value === '' || $value === null) {
return '';
}
$number = max(2, min(8, (int)$value));
return (string)$number;
}
private static function normalizeAngle(mixed $value): string
{
if ($value === '' || $value === null) {
return '';
}
$number = max(-360, min(360, (int)$value));
return (string)$number;
}
private static function colorCompanionDefaults(): array
{
$defaults = [];
foreach (array_keys(self::colorDefinitions()) as $field) {
foreach (self::colorCompanionFields($field) as $companionField) {
$defaults[$companionField] = '';
}
}
return $defaults;
}
private static function colorCompanionFields(string $field): array
{
return [
$field . 'Fill',
$field . 'FillStops',
$field . 'Angle',
];
}
private static function variantOptions(int $count): array
{
$options = [];
for ($index = 1; $index <= $count; $index++) {
$key = sprintf('variant%02d', $index);
$options[$key] = 'Variante ' . $index;
}
return $options;
}
}

366
src/App/Avatar/Lorelei.php Normal file
View File

@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace App\Avatar;
final class Lorelei
{
public static function key(): string
{
return 'lorelei';
}
public static function label(): string
{
return 'Lorelei';
}
public static function defaultSeed(): string
{
return 'papa-kind-treff';
}
public static function defaultConfig(): array
{
return array_merge([
'eyesVariant' => '',
'eyebrowsVariant' => '',
'mouthVariant' => '',
'glassesVariant' => '',
'hairVariant' => '',
'beardVariant' => '',
'earringsVariant' => '',
'earringsColor' => '',
'eyebrowsColor' => '',
'eyesColor' => '',
'frecklesColor' => '',
'glassesColor' => '',
'hairColor' => '',
'hairAccessoriesColor' => '',
'mouthColor' => '',
'noseColor' => '',
'skinColor' => '',
'backgroundColor' => '',
], self::colorCompanionDefaults());
}
public static function options(): array
{
return [
'eyesVariant' => self::variantOptions(24),
'eyebrowsVariant' => self::variantOptions(13),
'mouthVariant' => self::mouthVariantOptions(),
'glassesVariant' => ['' => 'Keine'] + self::variantOptions(5),
'hairVariant' => ['' => 'Keine'] + self::variantOptions(48),
'beardVariant' => ['' => 'Keine'] + self::variantOptions(2),
'earringsVariant' => ['' => 'Keine'] + self::variantOptions(3),
];
}
public static function componentDefinitions(): array
{
return [
'eyesVariant' => ['label' => 'Augen'],
'eyebrowsVariant' => ['label' => 'Augenbrauen'],
'mouthVariant' => ['label' => 'Mund'],
'glassesVariant' => ['label' => 'Brille'],
'hairVariant' => ['label' => 'Haare'],
'beardVariant' => ['label' => 'Bart'],
'earringsVariant' => ['label' => 'Ohrringe'],
];
}
public static function colorDefinitions(): array
{
return [
'earringsColor' => ['label' => 'Ohrringe', 'default' => '#d6b36b'],
'eyebrowsColor' => ['label' => 'Augenbrauen', 'default' => '#4a3227'],
'eyesColor' => ['label' => 'Augen', 'default' => '#2a2522'],
'frecklesColor' => ['label' => 'Sommersprossen', 'default' => '#b67852'],
'glassesColor' => ['label' => 'Brille', 'default' => '#26313f'],
'hairColor' => ['label' => 'Haare', 'default' => '#4b3128'],
'hairAccessoriesColor' => ['label' => 'Haar-Accessoires', 'default' => '#b96f54'],
'mouthColor' => ['label' => 'Mund', 'default' => '#9f5a56'],
'noseColor' => ['label' => 'Nase', 'default' => '#a56f58'],
'skinColor' => ['label' => 'Haut', 'default' => '#e7ba9a'],
'backgroundColor' => ['label' => 'Hintergrund', 'default' => '#f5efe5'],
];
}
public static function legacyColumnsFromConfig(string $seed, array $config): array
{
$config = self::normalizeConfig($config);
return [
'avatar_preset' => self::normalizeSeed($seed),
'avatar_lorelei_eyes_variant' => $config['eyesVariant'],
'avatar_lorelei_eyebrows_variant' => $config['eyebrowsVariant'],
'avatar_lorelei_mouth_variant' => $config['mouthVariant'],
'avatar_lorelei_glasses_variant' => $config['glassesVariant'],
'avatar_lorelei_hair_variant' => $config['hairVariant'],
'avatar_lorelei_beard_variant' => $config['beardVariant'],
'avatar_lorelei_earrings_variant' => $config['earringsVariant'],
];
}
public static function legacyConfig(array $input): array
{
return self::normalizeConfig([
'eyesVariant' => (string)($input['avatar_lorelei_eyes_variant'] ?? ''),
'eyebrowsVariant' => (string)($input['avatar_lorelei_eyebrows_variant'] ?? ''),
'mouthVariant' => (string)($input['avatar_lorelei_mouth_variant'] ?? ''),
'glassesVariant' => (string)($input['avatar_lorelei_glasses_variant'] ?? ''),
'hairVariant' => (string)($input['avatar_lorelei_hair_variant'] ?? ''),
'beardVariant' => (string)($input['avatar_lorelei_beard_variant'] ?? ''),
'earringsVariant' => (string)($input['avatar_lorelei_earrings_variant'] ?? ''),
]);
}
public static function normalizeSeed(string $seed): string
{
$seed = strtolower(trim($seed));
$seed = preg_replace('/[^a-z0-9]+/', '-', $seed) ?: '';
$seed = trim($seed, '-');
if ($seed === '') {
$seed = self::defaultSeed();
}
return substr($seed, 0, 100);
}
public static function normalizeConfig(array $input): array
{
$defaults = self::defaultConfig();
$normalized = [];
foreach ($defaults as $key => $defaultValue) {
if (array_key_exists($key, self::colorDefinitions())) {
$normalized[$key] = self::normalizeColor((string)($input[$key] ?? $defaultValue));
continue;
}
if (str_ends_with($key, 'Fill')) {
$normalized[$key] = self::normalizeFill((string)($input[$key] ?? $defaultValue));
continue;
}
if (str_ends_with($key, 'FillStops')) {
$normalized[$key] = self::normalizeFillStops($input[$key] ?? $defaultValue);
continue;
}
if (str_ends_with($key, 'Angle')) {
$normalized[$key] = self::normalizeAngle($input[$key] ?? $defaultValue);
continue;
}
$normalized[$key] = self::normalizeVariant((string)($input[$key] ?? $defaultValue));
}
return $normalized;
}
public static function buildQuery(string $seed, array $config): array
{
$config = self::normalizeConfig($config);
$query = ['seed' => self::normalizeSeed($seed)];
$requiredMap = [
'eyesVariant' => 'eyesVariant',
'eyebrowsVariant' => 'eyebrowsVariant',
'mouthVariant' => 'mouthVariant',
];
$optionalMap = [
'glassesVariant' => ['variant' => 'glassesVariant', 'probability' => 'glassesProbability'],
'beardVariant' => ['variant' => 'beardVariant', 'probability' => 'beardProbability'],
'earringsVariant' => ['variant' => 'earringsVariant', 'probability' => 'earringsProbability'],
];
foreach ($requiredMap as $key => $param) {
if ($config[$key] !== '') {
$query[$param] = $config[$key];
}
}
$query['hairProbability'] = '100';
if ($config['hairVariant'] !== '') {
$query['hairVariant'] = $config['hairVariant'];
}
foreach ($optionalMap as $key => $params) {
if ($config[$key] === '') {
$query[$params['probability']] = '0';
continue;
}
$query[$params['variant']] = $config[$key];
$query[$params['probability']] = '100';
}
foreach (array_keys(self::colorDefinitions()) as $field) {
if ($config[$field] !== '') {
$query[$field] = $config[$field];
foreach (self::colorCompanionFields($field) as $companionField) {
if ($config[$companionField] !== '') {
$query[$companionField] = $config[$companionField];
}
}
}
}
return $query;
}
public static function queryToConfig(array $query): array
{
$config = self::defaultConfig();
$map = [
'eyesVariant' => 'eyesVariant',
'eyebrowsVariant' => 'eyebrowsVariant',
'mouthVariant' => 'mouthVariant',
'glassesVariant' => 'glassesVariant',
'hairVariant' => 'hairVariant',
'beardVariant' => 'beardVariant',
'earringsVariant' => 'earringsVariant',
];
foreach ($map as $field => $param) {
$config[$field] = self::normalizeVariant((string)($query[$param] ?? ''));
}
if ((int)($query['glassesProbability'] ?? 100) <= 0) {
$config['glassesVariant'] = '';
}
if ((int)($query['beardProbability'] ?? 100) <= 0) {
$config['beardVariant'] = '';
}
if ((int)($query['earringsProbability'] ?? 100) <= 0) {
$config['earringsVariant'] = '';
}
if ((int)($query['hairProbability'] ?? 100) <= 0) {
$config['hairVariant'] = '';
}
foreach (array_keys(self::colorDefinitions()) as $field) {
$config[$field] = self::normalizeColor((string)($query[$field] ?? ''));
foreach (self::colorCompanionFields($field) as $companionField) {
$config[$companionField] = (string)($query[$companionField] ?? '');
}
}
return self::normalizeConfig($config);
}
public static function fallbackSvg(string $seed, string $label = 'Avatar'): string
{
$seed = self::normalizeSeed($seed);
$initials = strtoupper(substr(preg_replace('/[^a-z]/', '', $seed) ?: 'pk', 0, 2));
$palette = ['#f5efe5', '#d9c7b3', '#8f6f54', '#37485a', '#c88f5b'];
$hash = abs((int) crc32($seed));
$bg = $palette[$hash % count($palette)];
$fg = $hash % 2 === 0 ? '#243142' : '#ffffff';
return sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="%s"><rect width="128" height="128" rx="28" fill="%s"/><circle cx="64" cy="50" r="24" fill="rgba(255,255,255,0.26)"/><path d="M30 110c4-18 18-30 34-30s30 12 34 30" fill="rgba(255,255,255,0.2)"/><text x="64" y="76" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" font-weight="700" fill="%s">%s</text></svg>',
htmlspecialchars($label, ENT_QUOTES),
htmlspecialchars($bg, ENT_QUOTES),
htmlspecialchars($fg, ENT_QUOTES),
htmlspecialchars($initials, ENT_QUOTES)
);
}
private static function variantOptions(int $count): array
{
$options = [];
for ($index = 1; $index <= $count; $index++) {
$key = sprintf('variant%02d', $index);
$options[$key] = 'Variante ' . $index;
}
return $options;
}
private static function mouthVariantOptions(): array
{
$options = [];
for ($index = 1; $index <= 18; $index++) {
$key = sprintf('happy%02d', $index);
$options[$key] = 'Freundlich ' . $index;
}
for ($index = 1; $index <= 9; $index++) {
$key = sprintf('sad%02d', $index);
$options[$key] = 'Zurückhaltend ' . $index;
}
return $options;
}
public static function normalizeVariant(string $value): string
{
$value = strtolower(trim($value));
if ($value === '') {
return '';
}
return preg_match('/^(variant\d{2}|happy\d{2}|sad\d{2})$/', $value) === 1 ? $value : '';
}
private static function normalizeColor(string $value): string
{
$value = strtolower(trim($value));
if ($value === '') {
return '';
}
return preg_match('/^#[0-9a-f]{6}$/', $value) === 1 ? $value : '';
}
private static function normalizeFill(string $value): string
{
$value = strtolower(trim($value));
if ($value === '') {
return '';
}
return in_array($value, ['solid', 'linear', 'radial'], true) ? $value : '';
}
private static function normalizeFillStops(mixed $value): string
{
if ($value === '' || $value === null) {
return '';
}
$number = max(2, min(8, (int)$value));
return (string)$number;
}
private static function normalizeAngle(mixed $value): string
{
if ($value === '' || $value === null) {
return '';
}
$number = max(-360, min(360, (int)$value));
return (string)$number;
}
private static function colorCompanionDefaults(): array
{
$defaults = [];
foreach (array_keys(self::colorDefinitions()) as $field) {
foreach (self::colorCompanionFields($field) as $companionField) {
$defaults[$companionField] = '';
}
}
return $defaults;
}
private static function colorCompanionFields(string $field): array
{
return [
$field . 'Fill',
$field . 'FillStops',
$field . 'Angle',
];
}
}

366
src/App/CalendarSync.php Normal file
View File

@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace App;
final class CalendarSync
{
public function __construct(private App $app)
{
}
public function ensureSchema(): void
{
$this->pdo()->exec(
'CREATE TABLE IF NOT EXISTS user_calendar_feeds (
user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
token_encrypted TEXT NOT NULL,
token_lookup_hash CHAR(64) NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
rotated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_accessed_at DATETIME NULL,
CONSTRAINT fk_user_calendar_feed_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_calendar_feeds_lookup (token_lookup_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
}
public function getOrCreateFeedToken(int $userId): string
{
$existing = $this->getFeedToken($userId);
if ($existing !== null) {
return $existing;
}
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$crypto = $this->crypto();
$stmt = $this->pdo()->prepare(
'INSERT INTO user_calendar_feeds (user_id, token_encrypted, token_lookup_hash, created_at, rotated_at)
VALUES (:userId, :tokenEncrypted, :tokenLookupHash, NOW(), NOW())
ON DUPLICATE KEY UPDATE
token_encrypted = VALUES(token_encrypted),
token_lookup_hash = VALUES(token_lookup_hash),
rotated_at = NOW()'
);
$stmt->execute([
'userId' => $userId,
'tokenEncrypted' => $crypto->encrypt($token),
'tokenLookupHash' => hash('sha256', $token),
]);
return $token;
}
public function getFeedToken(int $userId): ?string
{
$stmt = $this->pdo()->prepare('SELECT token_encrypted FROM user_calendar_feeds WHERE user_id = :userId LIMIT 1');
$stmt->execute(['userId' => $userId]);
$encrypted = $stmt->fetchColumn();
if (!is_string($encrypted) || trim($encrypted) === '') {
return null;
}
$token = $this->crypto()->decrypt($encrypted);
return is_string($token) && trim($token) !== '' ? $token : null;
}
public function findUserIdByFeedToken(string $token): ?int
{
$token = trim($token);
if ($token === '') {
return null;
}
$stmt = $this->pdo()->prepare(
'SELECT user_id
FROM user_calendar_feeds
WHERE token_lookup_hash = :tokenLookupHash
LIMIT 1'
);
$stmt->execute(['tokenLookupHash' => hash('sha256', $token)]);
$userId = $stmt->fetchColumn();
return $userId !== false ? (int)$userId : null;
}
public function touchFeedAccess(int $userId): void
{
$stmt = $this->pdo()->prepare(
'UPDATE user_calendar_feeds
SET last_accessed_at = NOW()
WHERE user_id = :userId'
);
$stmt->execute(['userId' => $userId]);
}
public function listCalendarEventsForUser(int $userId): array
{
$stmt = $this->pdo()->prepare(
'SELECT e.id, e.created_by, e.title, e.description, e.category_slug, e.street, e.zip, e.city, e.region,
e.starts_at, e.ends_at, e.visibility, e.status, e.allow_kids,
"owner" AS relation_type,
NULL AS participation_status,
NULL AS host_name
FROM events e
WHERE e.created_by = :userId
UNION ALL
SELECT e.id, e.created_by, e.title, e.description, e.category_slug, e.street, e.zip, e.city, e.region,
e.starts_at, e.ends_at, e.visibility, e.status, e.allow_kids,
"participant" AS relation_type,
ep.status AS participation_status,
COALESCE(up.display_name, "Mitglied") AS host_name
FROM event_participants ep
INNER JOIN events e ON e.id = ep.event_id
INNER JOIN users u ON u.id = e.created_by
LEFT JOIN user_profiles up ON up.user_id = u.id
WHERE ep.user_id = :participantId
AND e.created_by <> :ownerId
AND ep.status <> "cancelled"
ORDER BY starts_at ASC, id ASC'
);
$stmt->execute([
'userId' => $userId,
'participantId' => $userId,
'ownerId' => $userId,
]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
public function renderUserCalendarIcs(int $userId, string $calendarName = 'Papa-Kind-Treff Events'): string
{
return $this->renderCalendarIcs($calendarName, $this->listCalendarEventsForUser($userId));
}
public function renderCalendarIcs(string $calendarName, array $events): string
{
$lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Papa-Kind-Treff//Kalender//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:' . $this->escapeText($calendarName),
'X-WR-TIMEZONE:Europe/Berlin',
];
foreach ($events as $event) {
$lines = array_merge($lines, $this->buildEventLines($event));
}
$lines[] = 'END:VCALENDAR';
return $this->foldLines($lines);
}
public static function buildAbsoluteUrl(string $path): string
{
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
return $scheme . '://' . $host . $path;
}
private function buildEventLines(array $event): array
{
$lines = [
'BEGIN:VEVENT',
'UID:' . $this->buildUid((int)($event['id'] ?? 0), (string)($event['relation_type'] ?? 'event')),
'DTSTAMP:' . gmdate('Ymd\THis\Z'),
'SUMMARY:' . $this->escapeText((string)($event['title'] ?? 'Papa-Kind-Treff Event')),
];
$startAt = trim((string)($event['starts_at'] ?? ''));
$endAt = trim((string)($event['ends_at'] ?? ''));
$isAllDay = $this->isAllDayEvent($startAt, $endAt);
if ($isAllDay) {
$startDate = $this->toDateString($startAt);
if ($startDate !== null) {
$lines[] = 'DTSTART;VALUE=DATE:' . $startDate;
$lines[] = 'DTEND;VALUE=DATE:' . $this->incrementDateString($startDate);
}
} else {
$startDateTime = $this->toDateTimeString($startAt);
if ($startDateTime !== null) {
$lines[] = 'DTSTART;TZID=Europe/Berlin:' . $startDateTime;
}
$endDateTime = $this->toDateTimeString($endAt);
if ($endDateTime !== null) {
$lines[] = 'DTEND;TZID=Europe/Berlin:' . $endDateTime;
}
}
$description = $this->buildDescription($event);
if ($description !== '') {
$lines[] = 'DESCRIPTION:' . $this->escapeText($description);
}
$location = $this->buildLocation($event);
if ($location !== '') {
$lines[] = 'LOCATION:' . $this->escapeText($location);
}
if (($event['status'] ?? '') === 'cancelled') {
$lines[] = 'STATUS:CANCELLED';
} else {
$lines[] = 'STATUS:CONFIRMED';
}
$lines[] = 'END:VEVENT';
return $lines;
}
private function buildDescription(array $event): string
{
$parts = [];
$description = trim((string)($event['description'] ?? ''));
if ($description !== '') {
$parts[] = $description;
}
$meta = [];
if (($event['relation_type'] ?? '') === 'owner') {
$meta[] = 'Typ: Eigenes Event';
} elseif (($event['relation_type'] ?? '') === 'participant') {
$meta[] = 'Typ: Teilnahme an fremdem Event';
}
if (!empty($event['host_name'])) {
$meta[] = 'Veranstalter: ' . trim((string)$event['host_name']);
}
if (!empty($event['participation_status'])) {
$meta[] = 'Teilnahmestatus: ' . trim((string)$event['participation_status']);
}
if (!empty($event['category_slug'])) {
$meta[] = 'Kategorie: ' . trim((string)$event['category_slug']);
}
$meta[] = !empty($event['allow_kids']) ? 'Mit Kindern: Ja' : 'Mit Kindern: Nein';
$meta[] = 'Sichtbarkeit: ' . (($event['visibility'] ?? 'public') === 'members' ? 'Nur Mitglieder' : 'Öffentlich');
if (($event['status'] ?? '') === 'cancelled') {
$meta[] = 'Status: Abgesagt';
}
if ($meta !== []) {
$parts[] = implode("\n", $meta);
}
return trim(implode("\n\n", $parts));
}
private function buildLocation(array $event): string
{
$parts = array_filter([
trim((string)($event['street'] ?? '')),
trim((string)implode(' ', array_filter([
(string)($event['zip'] ?? ''),
(string)($event['city'] ?? ''),
]))),
trim((string)($event['region'] ?? '')),
]);
return trim(implode(', ', $parts));
}
private function buildUid(int $eventId, string $relationType): string
{
$host = preg_replace('/[^a-z0-9.-]+/i', '-', (string)($_SERVER['HTTP_HOST'] ?? 'papa-kind-treff.local')) ?: 'papa-kind-treff.local';
return sprintf('event-%d-%s@%s', $eventId, $relationType, $host);
}
private function isAllDayEvent(string $startAt, string $endAt): bool
{
if ($startAt === '') {
return false;
}
$startTime = substr($startAt, 11, 8);
$endTime = $endAt !== '' ? substr($endAt, 11, 8) : '';
return $startTime === '' || $startTime === '00:00:00' || $startTime === '23:59:59'
? ($endAt === '' || $endTime === '00:00:00' || $endTime === '23:59:59')
: false;
}
private function toDateString(string $value): ?string
{
if ($value === '') {
return null;
}
try {
return (new \DateTimeImmutable($value))->format('Ymd');
} catch (\Throwable) {
return null;
}
}
private function incrementDateString(string $dateString): string
{
$date = \DateTimeImmutable::createFromFormat('Ymd', $dateString);
if (!$date instanceof \DateTimeImmutable) {
return $dateString;
}
return $date->modify('+1 day')->format('Ymd');
}
private function toDateTimeString(string $value): ?string
{
if ($value === '') {
return null;
}
try {
return (new \DateTimeImmutable($value, new \DateTimeZone('Europe/Berlin')))->format('Ymd\THis');
} catch (\Throwable) {
return null;
}
}
private function escapeText(string $value): string
{
$value = str_replace(["\r\n", "\r"], "\n", trim($value));
$value = str_replace('\\', '\\\\', $value);
$value = str_replace(';', '\;', $value);
$value = str_replace(',', '\,', $value);
return str_replace("\n", '\n', $value);
}
private function foldLines(array $lines): string
{
$output = [];
foreach ($lines as $line) {
$line = (string)$line;
while (strlen($line) > 75) {
$output[] = substr($line, 0, 75);
$line = ' ' . substr($line, 75);
}
$output[] = $line;
}
return implode("\r\n", $output) . "\r\n";
}
private function pdo(): \PDO
{
$pdo = $this->app->pdo();
if (!$pdo instanceof \PDO) {
throw new \RuntimeException('Datenbankverbindung nicht verfügbar.');
}
return $pdo;
}
private function crypto(): Crypto
{
return new Crypto($this->app->config());
}
}

View File

@@ -3,9 +3,12 @@ declare(strict_types=1);
namespace App;
use App\Avatar\AvatarManager;
final class Community
{
private ?array $forumStructure = null;
private ?array $membershipLevelCache = null;
private array $tableCache = [];
private array $columnCache = [];
@@ -150,9 +153,13 @@ final class Community
$where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : '';
$avatarColumns = $this->avatarProfileSelect('p');
$avatarSelect = $avatarColumns !== ''
? ', ' . $avatarColumns
: '';
$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,
p.display_name$avatarSelect,
$boardSelect,
(SELECT COUNT(*) FROM forum_posts fp WHERE fp.thread_id = ft.id) AS answers,
COALESCE(
@@ -203,6 +210,10 @@ final class Community
public function getThread(int $id): ?array
{
$select = 'ft.*, p.display_name';
$avatarColumns = $this->avatarProfileSelect('p');
if ($avatarColumns !== '') {
$select .= ', ' . $avatarColumns;
}
$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';
@@ -220,6 +231,10 @@ final class Community
public function listPosts(int $threadId): array
{
$select = 'fp.*, p.display_name';
$avatarColumns = $this->avatarProfileSelect('p');
if ($avatarColumns !== '') {
$select .= ', ' . $avatarColumns;
}
if ($this->hasColumn('forum_posts', 'highlighted_at')) {
$select .= ', hp.display_name AS highlighted_by_name';
} else {
@@ -402,20 +417,253 @@ final class Community
return $amount;
}
public function membershipLevel(float $points): array
public function adjustPoints(int $userId, float $amount, string $reason = '', ?int $actingUserId = null): float
{
$levels = $this->config['levels'] ?? [];
usort($levels, fn($a,$b) => ($b['min'] ?? 0) <=> ($a['min'] ?? 0));
foreach ($levels as $lvl) {
if ($points >= (float)($lvl['min'] ?? 0)) {
return [
'label' => $lvl['label'] ?? 'New Daddy',
'icon' => $lvl['icon'] ?? '',
];
if ($userId <= 0) {
throw new \RuntimeException('Benutzer nicht gefunden.');
}
if ($amount == 0.0) {
throw new \RuntimeException('Bitte gib eine Punkteänderung ungleich 0 an.');
}
$meta = [
'reason' => trim($reason),
];
if ($actingUserId !== null && $actingUserId > 0) {
$meta['acting_user_id'] = $actingUserId;
}
$stmt = $this->pdo->prepare('INSERT INTO user_points (user_id, action, amount, meta) VALUES (:uid, :action, :amount, :meta)');
$stmt->execute([
':uid' => $userId,
':action' => 'manual.adjustment',
':amount' => $amount,
':meta' => json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
$stmt = $this->pdo->prepare('INSERT INTO user_points_totals (user_id, total) VALUES (:uid, :amt) ON DUPLICATE KEY UPDATE total = total + VALUES(total)');
$stmt->execute([':uid' => $userId, ':amt' => $amount]);
return $amount;
}
public function listRecentlyReachedLevelUsers(float $minPoints, int $days = 30, int $limit = 12): array
{
if ($minPoints <= 0) {
return [];
}
$days = max(1, min(365, $days));
$limit = max(1, min(100, $limit));
$cutoff = (new \DateTimeImmutable('today 23:59:59'))->modify('-' . $days . ' days');
$candidateStmt = $this->pdo->prepare(
'SELECT upt.user_id, upt.total, up.display_name, up.first_name, up.last_name
FROM user_points_totals upt
JOIN users u ON u.id = upt.user_id
LEFT JOIN user_profiles up ON up.user_id = upt.user_id
WHERE upt.total >= :minPoints
ORDER BY upt.total DESC, upt.updated_at DESC
LIMIT ' . $limit
);
$candidateStmt->execute(['minPoints' => $minPoints]);
$candidates = $candidateStmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
if ($candidates === []) {
return [];
}
$candidateIds = array_values(array_map(static fn(array $row): int => (int)($row['user_id'] ?? 0), $candidates));
$candidateIds = array_values(array_filter($candidateIds, static fn(int $id): bool => $id > 0));
if ($candidateIds === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($candidateIds), '?'));
$pointsStmt = $this->pdo->prepare(
"SELECT user_id, amount, created_at
FROM user_points
WHERE user_id IN ($placeholders)
ORDER BY user_id ASC, created_at ASC, id ASC"
);
$pointsStmt->execute($candidateIds);
$pointRows = $pointsStmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
$reachedAtByUser = [];
$runningTotals = [];
foreach ($pointRows as $pointRow) {
$entryUserId = (int)($pointRow['user_id'] ?? 0);
if ($entryUserId <= 0) {
continue;
}
$runningTotals[$entryUserId] = ($runningTotals[$entryUserId] ?? 0.0) + (float)($pointRow['amount'] ?? 0.0);
if (isset($reachedAtByUser[$entryUserId])) {
continue;
}
if ($runningTotals[$entryUserId] >= $minPoints) {
$reachedAtByUser[$entryUserId] = (string)($pointRow['created_at'] ?? '');
}
}
$fallback = $levels ? $levels[count($levels)-1] : ['label' => 'New Daddy','icon' => ''];
return ['label' => $fallback['label'], 'icon' => $fallback['icon'] ?? ''];
$recentUsers = [];
foreach ($candidates as $candidate) {
$candidateUserId = (int)($candidate['user_id'] ?? 0);
$reachedAtRaw = (string)($reachedAtByUser[$candidateUserId] ?? '');
if ($candidateUserId <= 0 || $reachedAtRaw === '') {
continue;
}
try {
$reachedAt = new \DateTimeImmutable($reachedAtRaw);
} catch (\Throwable) {
continue;
}
if ($reachedAt < $cutoff) {
continue;
}
$recentUsers[] = [
'user_id' => $candidateUserId,
'display_name' => (string)($candidate['display_name'] ?? ''),
'first_name' => (string)($candidate['first_name'] ?? ''),
'last_name' => (string)($candidate['last_name'] ?? ''),
'points' => (float)($candidate['total'] ?? 0.0),
'level' => $this->membershipLevel((float)($candidate['total'] ?? 0.0)),
'reached_at' => $reachedAt->format('Y-m-d H:i:s'),
];
}
usort($recentUsers, static function (array $left, array $right): int {
return strcmp((string)($right['reached_at'] ?? ''), (string)($left['reached_at'] ?? ''));
});
return array_slice($recentUsers, 0, $limit);
}
public function membershipLevel(float $points): array
{
$level = $this->membershipLevelMeta($points);
return [
'label' => (string)($level['label'] ?? 'Neuer Vater'),
'icon' => (string)($level['icon'] ?? ''),
];
}
public function membershipLevelMeta(float $points): array
{
$levels = $this->listMembershipLevels();
$sortedLevels = $levels;
usort($sortedLevels, fn(array $a, array $b): int => ((float)($b['min'] ?? 0.0)) <=> ((float)($a['min'] ?? 0.0)));
foreach ($sortedLevels as $level) {
if ($points >= (float)($level['min'] ?? 0.0)) {
return $level;
}
}
return $sortedLevels !== [] ? $sortedLevels[array_key_last($sortedLevels)] : $this->normalizeMembershipLevel(['label' => 'Neuer Vater', 'min' => 0], 0);
}
public function listMembershipLevels(): array
{
if ($this->membershipLevelCache !== null) {
return $this->membershipLevelCache;
}
$levels = $this->config['levels'] ?? [];
try {
$settings = new SystemSettings($this->pdo);
$rawLevels = trim((string)$settings->get('community_levels_json', ''));
if ($rawLevels !== '') {
$decodedLevels = json_decode($rawLevels, true);
if (is_array($decodedLevels)) {
$levels = $decodedLevels;
}
}
} catch (\Throwable) {
}
$normalizedLevels = [];
foreach ($levels as $index => $level) {
if (!is_array($level)) {
continue;
}
$normalizedLevels[] = $this->normalizeMembershipLevel($level, (int)$index);
}
if ($normalizedLevels === []) {
$normalizedLevels[] = $this->normalizeMembershipLevel(['label' => 'Neuer Vater', 'min' => 0], 0);
}
usort($normalizedLevels, function (array $left, array $right): int {
$minCompare = ((float)($left['min'] ?? 0.0)) <=> ((float)($right['min'] ?? 0.0));
if ($minCompare !== 0) {
return $minCompare;
}
return strcmp((string)($left['label'] ?? ''), (string)($right['label'] ?? ''));
});
return $this->membershipLevelCache = array_values($normalizedLevels);
}
public function saveMembershipLevels(array $levels, ?int $updatedBy = null): void
{
$normalizedLevels = [];
foreach ($levels as $index => $level) {
if (!is_array($level)) {
continue;
}
$normalizedLevels[] = $this->normalizeMembershipLevel($level, (int)$index);
}
if ($normalizedLevels === []) {
throw new \RuntimeException('Mindestens ein Community-Level muss vorhanden sein.');
}
$seenIds = [];
foreach ($normalizedLevels as $level) {
$levelId = (string)($level['id'] ?? '');
if ($levelId === '' || isset($seenIds[$levelId])) {
throw new \RuntimeException('Jedes Community-Level braucht eine eindeutige Kennung.');
}
$seenIds[$levelId] = true;
}
usort($normalizedLevels, function (array $left, array $right): int {
$minCompare = ((float)($left['min'] ?? 0.0)) <=> ((float)($right['min'] ?? 0.0));
if ($minCompare !== 0) {
return $minCompare;
}
return strcmp((string)($left['label'] ?? ''), (string)($right['label'] ?? ''));
});
$settings = new SystemSettings($this->pdo);
$settings->ensureSchema();
$settings->set(
'community_levels_json',
json_encode($normalizedLevels, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: '[]',
$updatedBy
);
$this->membershipLevelCache = array_values($normalizedLevels);
}
public static function membershipRightDefinitions(): array
{
return [
'can_highlight_helpful' => [
'label' => 'Hilfreiche Antworten hervorheben',
],
'can_apply_for_forum_admin' => [
'label' => 'Bewerbung als Forum-Admin',
],
'can_manage_categories' => [
'label' => 'Kategorien bestätigen und zusammenführen',
],
'can_review_listings' => [
'label' => 'Orte und Veranstaltungen freigeben',
],
];
}
private function forumStructure(): array
@@ -527,4 +775,78 @@ final class Community
return $this->columnCache[$key] = false;
}
}
private function avatarProfileSelect(string $alias): string
{
$columns = [];
foreach (AvatarManager::allProfileColumns() as $column) {
if ($this->hasColumn('user_profiles', $column)) {
$columns[] = $alias . '.' . $column;
}
}
return implode(', ', $columns);
}
private function normalizeMembershipLevel(array $level, int $index): array
{
$label = trim((string)($level['label'] ?? ''));
if ($label === '') {
$label = 'Level ' . ($index + 1);
}
$minValue = is_numeric($level['min'] ?? null) ? (float)$level['min'] : 0.0;
if ($minValue < 0) {
$minValue = 0.0;
}
$levelId = trim((string)($level['id'] ?? ''));
if ($levelId === '') {
$levelId = $this->slugifyLevelIdentifier($label . '-' . (string)$minValue . '-' . (string)$index);
}
$rightsInput = is_array($level['rights'] ?? null) ? $level['rights'] : [];
$rights = [];
foreach (array_keys(self::membershipRightDefinitions()) as $rightKey) {
if (array_key_exists($rightKey, $rightsInput)) {
$rights[$rightKey] = $this->toBool($rightsInput[$rightKey]);
continue;
}
$rights[$rightKey] = match ($rightKey) {
'can_highlight_helpful' => $minValue >= 500.0,
'can_apply_for_forum_admin' => $minValue >= 750.0,
'can_manage_categories' => $minValue >= 750.0,
'can_review_listings' => $minValue >= 750.0,
default => false,
};
}
return [
'id' => $levelId,
'min' => $minValue,
'label' => $label,
'icon' => trim((string)($level['icon'] ?? '')),
'rights' => $rights,
];
}
private function slugifyLevelIdentifier(string $value): string
{
$value = mb_strtolower(trim($value));
$value = strtr($value, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?: '';
$value = trim($value, '-');
return $value !== '' ? $value : 'community-level';
}
private function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return in_array((string)$value, ['1', 'true', 'yes', 'on'], true);
}
}

View File

@@ -7,11 +7,22 @@ final class CommunityAccess
{
private array $tableCache = [];
private array $columnCache = [];
private ?UserEmailStore $emailStore = null;
public function __construct(private \PDO $pdo, private array $communityConfig)
{
}
private function emailStore(): UserEmailStore
{
if ($this->emailStore === null) {
$this->emailStore = new UserEmailStore($this->pdo);
$this->emailStore->ensureSchema();
}
return $this->emailStore;
}
public function getUserRoles(int $userId): array
{
if ($userId <= 0) {
@@ -64,6 +75,32 @@ final class CommunityAccess
return $this->hasRole($userId, 'owner');
}
public function canManageCategories(int $userId, ?float $points = null): bool
{
if ($this->hasRole($userId, 'owner') || $this->hasRole($userId, 'site_admin') || $this->hasRole($userId, 'forum_admin')) {
return true;
}
return $this->resolveLevelRight($points ?? $this->resolveUserPoints($userId), 'can_manage_categories');
}
public function canReviewListings(int $userId, ?float $points = null): bool
{
if ($this->hasRole($userId, 'owner') || $this->hasRole($userId, 'site_admin') || $this->hasRole($userId, 'forum_admin')) {
return true;
}
return $this->resolveLevelRight($points ?? $this->resolveUserPoints($userId), 'can_review_listings');
}
public function canAccessCommunityAdmin(int $userId, ?float $points = null): bool
{
return $this->canModerateForum($userId)
|| $this->canManageApplications($userId)
|| $this->canManageRoles($userId)
|| $this->canReviewListings($userId, $points);
}
public function getRestrictionState(int $userId): array
{
$state = [
@@ -108,7 +145,10 @@ final class CommunityAccess
public function canApplyForForumAdmin(int $userId, float $points): bool
{
if ($userId <= 0 || $points < 750.0 || !$this->hasTable('community_admin_applications')) {
if ($userId <= 0 || !$this->hasTable('community_admin_applications')) {
return false;
}
if (!$this->resolveLevelRight($points, 'can_apply_for_forum_admin')) {
return false;
}
@@ -182,7 +222,7 @@ final class CommunityAccess
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
return $this->emailStore()->decryptRowEmails($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []);
}
public function decideApplication(int $adminUserId, int $applicationId, string $decision, ?string $reason = null): void
@@ -273,7 +313,79 @@ final class CommunityAccess
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) ?: [];
return $this->emailStore()->decryptRowEmails($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [], 'user_id');
}
public function searchUsers(string $query, int $limit = 12): array
{
$query = trim($query);
if ($query === '') {
return [];
}
$limit = max(1, min(50, $limit));
$rowsById = [];
$tokens = array_values(array_filter(preg_split('/\s+/u', mb_strtolower($query)) ?: [], static fn(string $token): bool => $token !== ''));
if ($tokens !== []) {
$conditions = [];
$params = [];
foreach ($tokens as $index => $token) {
$displayKey = ':display_' . $index;
$conditions[] = 'LOWER(COALESCE(up.display_name, "")) LIKE ' . $displayKey;
$params[$displayKey] = '%' . $token . '%';
}
$sql = '
SELECT u.id, u.email, u.status, up.display_name, up.first_name, up.last_name
FROM users u
LEFT JOIN user_profiles up ON up.user_id = u.id
WHERE ' . implode(' AND ', $conditions) . '
ORDER BY
CASE
WHEN LOWER(COALESCE(up.display_name, "")) = :exactDisplayQuery THEN 0
ELSE 1
END,
COALESCE(up.display_name, ""),
u.id DESC
LIMIT ' . $limit;
$params[':exactDisplayQuery'] = mb_strtolower($query);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
$rowsById[(int)($row['id'] ?? 0)] = $row;
}
}
if (str_contains($query, '@')) {
$emailRow = $this->emailStore()->findUserByEmail($query, 'id, email, status');
if (is_array($emailRow)) {
$profileStmt = $this->pdo->prepare('SELECT display_name, first_name, last_name FROM user_profiles WHERE user_id = :uid LIMIT 1');
$profileStmt->execute(['uid' => (int)$emailRow['id']]);
$profileRow = $profileStmt->fetch(\PDO::FETCH_ASSOC) ?: [];
$emailRow['display_name'] = (string)($profileRow['display_name'] ?? '');
$emailRow['first_name'] = (string)($profileRow['first_name'] ?? '');
$emailRow['last_name'] = (string)($profileRow['last_name'] ?? '');
$rowsById[(int)$emailRow['id']] = $emailRow;
}
}
$rows = array_values($rowsById);
usort($rows, static function (array $left, array $right) use ($query): int {
$leftDisplay = mb_strtolower((string)($left['display_name'] ?? ''));
$rightDisplay = mb_strtolower((string)($right['display_name'] ?? ''));
$needle = mb_strtolower($query);
$leftRank = $leftDisplay === $needle ? 0 : 1;
$rightRank = $rightDisplay === $needle ? 0 : 1;
if ($leftRank !== $rightRank) {
return $leftRank <=> $rightRank;
}
return ((int)($right['id'] ?? 0)) <=> ((int)($left['id'] ?? 0));
});
$rows = array_slice($rows, 0, $limit);
return $this->emailStore()->decryptRowEmails($rows, 'id');
}
public function setRestriction(int $actingUserId, int $targetUserId, string $type, string $reason): void
@@ -439,7 +551,7 @@ final class CommunityAccess
public function canHighlightHelpful(float $points): bool
{
return $points >= 500.0;
return $this->resolveLevelRight($points, 'can_highlight_helpful');
}
public function supportsApplications(): bool
@@ -481,4 +593,21 @@ final class CommunityAccess
return $this->tableCache[$table] = false;
}
}
private function resolveLevelRight(float $points, string $rightKey): bool
{
$community = new Community($this->pdo, $this->communityConfig);
$level = $community->membershipLevelMeta($points);
return !empty($level['rights'][$rightKey]);
}
private function resolveUserPoints(int $userId): float
{
if ($userId <= 0) {
return 0.0;
}
$community = new Community($this->pdo, $this->communityConfig);
return $community->computePoints($userId);
}
}

View File

@@ -5,6 +5,7 @@ namespace App;
final class Crypto
{
private const ENCRYPTED_PREFIX = 'enc:';
private string $key;
public function __construct(Config $config)
@@ -44,7 +45,7 @@ final class Crypto
}
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $this->key);
return base64_encode($nonce . $cipher);
return self::ENCRYPTED_PREFIX . base64_encode($nonce . $cipher);
}
public function decrypt(?string $blob): string
@@ -52,7 +53,8 @@ final class Crypto
if ($blob === null || $blob === '') {
return '';
}
$raw = base64_decode($blob, true);
$payload = str_starts_with($blob, self::ENCRYPTED_PREFIX) ? substr($blob, strlen(self::ENCRYPTED_PREFIX)) : $blob;
$raw = base64_decode($payload, true);
if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES) {
return '';
}
@@ -65,4 +67,19 @@ final class Crypto
return '';
}
}
public static function looksEncrypted(?string $blob): bool
{
if ($blob === null || $blob === '') {
return false;
}
$payload = str_starts_with($blob, self::ENCRYPTED_PREFIX) ? substr($blob, strlen(self::ENCRYPTED_PREFIX)) : $blob;
$raw = base64_decode($payload, true);
if ($raw === false) {
return false;
}
return strlen($raw) > SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES;
}
}

View File

@@ -14,7 +14,7 @@ final class I18n
$this->fallback = [
'common' => [
'title' => 'Papa-Kind-Treff',
'intro' => 'Väter vernetzen sich für Treffen mit und ohne Kinder.',
'intro' => 'Väter vernetzen sich für Events, Termine und Treffen mit und ohne Kinder.',
],
'cta' => [
'primary' => 'Weiter',

1707
src/App/ListingCatalog.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace App;
/** Server-side lookup for external place providers. API keys never reach the browser. */
final class PlaceProviderLookup
{
public function search(array $entry, array $settings): array
{
$query = $this->buildQuery($entry);
if ($query === '') {
throw new \RuntimeException('Für die externe Ortssuche werden mindestens Name oder Adressdaten benötigt.');
}
$results = [];
$notices = [];
if (($settings['osm_places_enabled'] ?? '1') === '1') {
try {
$results = array_merge($results, $this->searchOsm($query, $entry));
} catch (\Throwable) {
$notices[] = 'OpenStreetMap konnte gerade nicht durchsucht werden.';
}
}
if (($settings['google_places_enabled'] ?? '0') === '1') {
if ($this->googleKey() === '') {
$notices[] = 'Google Places ist aktiviert, aber der Server-Schlüssel fehlt.';
} else {
try {
$results = array_merge($results, $this->searchGoogle($query, $entry));
} catch (\Throwable) {
$notices[] = 'Google Places konnte gerade nicht durchsucht werden.';
}
}
}
if (($settings['azure_maps_enabled'] ?? '0') === '1') {
if ($this->azureKey() === '') {
$notices[] = 'Azure Maps ist aktiviert, aber der Server-Schlüssel fehlt.';
} else {
try {
$results = array_merge($results, $this->searchAzure($query, $entry));
} catch (\Throwable) {
$notices[] = 'Azure Maps konnte gerade nicht durchsucht werden.';
}
}
}
usort($results, static function (array $a, array $b): int {
$aDistance = $a['distance_m'] ?? PHP_INT_MAX;
$bDistance = $b['distance_m'] ?? PHP_INT_MAX;
return $aDistance <=> $bDistance ?: strcmp((string)$a['name'], (string)$b['name']);
});
return ['query' => $query, 'results' => $results, 'notices' => $notices, 'searched_at' => time()];
}
public function googleDetails(string $placeResource): ?array
{
$key = $this->googleKey();
if ($key === '') {
return null;
}
$resource = ltrim($placeResource, '/');
if (!str_starts_with($resource, 'places/')) {
return null;
}
$data = $this->requestJson(
'GET',
'https://places.googleapis.com/v1/' . implode('/', array_map(rawurlencode(...), explode('/', $resource))),
null,
[
'X-Goog-Api-Key: ' . $key,
'X-Goog-FieldMask: id,displayName,formattedAddress,googleMapsUri,rating,userRatingCount',
]
);
if (!is_array($data) || empty($data['id'])) {
return null;
}
return [
'id' => (string)$data['id'],
'name' => (string)($data['displayName']['text'] ?? ''),
'address' => (string)($data['formattedAddress'] ?? ''),
'url' => (string)($data['googleMapsUri'] ?? ''),
'rating' => isset($data['rating']) ? (float)$data['rating'] : null,
'rating_count' => isset($data['userRatingCount']) ? (int)$data['userRatingCount'] : null,
];
}
private function searchOsm(string $query, array $entry): array
{
$results = [];
foreach ($this->osmQueries($query, $entry) as $osmQuery) {
$url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([
'format' => 'jsonv2',
'addressdetails' => 1,
'limit' => 3,
'q' => $osmQuery,
]);
$rows = $this->cachedOsmRequest($url);
if (!is_array($rows)) {
continue;
}
foreach ($rows as $row) {
if (!is_array($row) || empty($row['osm_type']) || empty($row['osm_id'])) {
continue;
}
$lat = isset($row['lat']) ? (float)$row['lat'] : null;
$lng = isset($row['lon']) ? (float)$row['lon'] : null;
$type = (string)$row['osm_type'];
$id = (string)$row['osm_id'];
$results[] = $this->result('osm', $type . ':' . $id, (string)($row['name'] ?? $row['display_name'] ?? ''), (string)($row['display_name'] ?? ''), $lat, $lng, $entry, 'https://www.openstreetmap.org/' . $type . '/' . rawurlencode($id));
}
if ($results !== []) {
break;
}
}
return $results;
}
private function osmQueries(string $primaryQuery, array $entry): array
{
$addressQuery = trim(implode(', ', array_filter([
(string)($entry['street'] ?? ''),
trim((string)($entry['zip'] ?? '') . ' ' . (string)($entry['city'] ?? '')),
'Deutschland',
])));
$nameCityQuery = trim(implode(', ', array_filter([
(string)($entry['title'] ?? $entry['place_title'] ?? ''),
(string)($entry['city'] ?? ''),
'Deutschland',
])));
return array_values(array_unique(array_filter([$primaryQuery, $addressQuery, $nameCityQuery])));
}
private function searchGoogle(string $query, array $entry): array
{
$payload = ['textQuery' => $query, 'languageCode' => 'de', 'regionCode' => 'DE'];
if (isset($entry['lat'], $entry['lng']) && $entry['lat'] !== null && $entry['lng'] !== null) {
$payload['locationBias'] = ['circle' => ['center' => ['latitude' => (float)$entry['lat'], 'longitude' => (float)$entry['lng']], 'radius' => 5000.0]];
}
$data = $this->requestJson('POST', 'https://places.googleapis.com/v1/places:searchText', $payload, [
'X-Goog-Api-Key: ' . $this->googleKey(),
'X-Goog-FieldMask: places.id,places.displayName,places.formattedAddress,places.location',
]);
$results = [];
foreach ((array)($data['places'] ?? []) as $row) {
if (!is_array($row) || empty($row['id'])) {
continue;
}
$location = (array)($row['location'] ?? []);
$results[] = $this->result('google', (string)$row['id'], (string)($row['displayName']['text'] ?? ''), (string)($row['formattedAddress'] ?? ''), isset($location['latitude']) ? (float)$location['latitude'] : null, isset($location['longitude']) ? (float)$location['longitude'] : null, $entry, null);
}
return $results;
}
private function searchAzure(string $query, array $entry): array
{
$params = ['api-version' => '1.0', 'subscription-key' => $this->azureKey(), 'query' => $query, 'limit' => 3, 'countrySet' => 'DE', 'language' => 'de-DE'];
if (isset($entry['lat'], $entry['lng']) && $entry['lat'] !== null && $entry['lng'] !== null) {
$params['lat'] = (float)$entry['lat'];
$params['lon'] = (float)$entry['lng'];
$params['radius'] = 5000;
}
$data = $this->requestJson('GET', 'https://eu.atlas.microsoft.com/search/poi/json?' . http_build_query($params));
$results = [];
foreach ((array)($data['results'] ?? []) as $row) {
if (!is_array($row) || empty($row['id'])) {
continue;
}
$poi = (array)($row['poi'] ?? []);
$address = (array)($row['address'] ?? []);
$results[] = $this->result('azure', (string)$row['id'], (string)($poi['name'] ?? ''), (string)($address['freeformAddress'] ?? ''), isset($row['position']['lat']) ? (float)$row['position']['lat'] : null, isset($row['position']['lon']) ? (float)$row['position']['lon'] : null, $entry, null);
}
return $results;
}
private function result(string $provider, string $id, string $name, string $address, ?float $lat, ?float $lng, array $entry, ?string $url): array
{
return ['provider' => $provider, 'id' => $id, 'name' => $name !== '' ? $name : $address, 'address' => $address, 'lat' => $lat, 'lng' => $lng, 'url' => $url, 'distance_m' => $lat !== null && $lng !== null && isset($entry['lat'], $entry['lng']) && $entry['lat'] !== null && $entry['lng'] !== null ? $this->distance((float)$entry['lat'], (float)$entry['lng'], $lat, $lng) : null];
}
private function buildQuery(array $entry): string
{
return trim(implode(', ', array_filter([(string)($entry['title'] ?? $entry['place_title'] ?? ''), (string)($entry['street'] ?? ''), trim((string)($entry['zip'] ?? '') . ' ' . (string)($entry['city'] ?? '')), (string)($entry['region'] ?? ''), 'Deutschland'])));
}
private function googleKey(): string { return trim((string)getenv('GOOGLE_MAPS_API_KEY')); }
private function azureKey(): string { return trim((string)getenv('AZURE_MAPS_SUBSCRIPTION_KEY')); }
private function requestJson(string $method, string $url, ?array $payload = null, array $headers = []): array
{
$headers[] = 'Accept: application/json';
$headers[] = 'User-Agent: Papa-Kind-Treff/1.0 (+https://papa-kind-treff.de/)';
$body = $payload === null ? null : json_encode($payload, JSON_THROW_ON_ERROR);
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
}
if (function_exists('curl_init')) {
$handle = curl_init($url);
curl_setopt_array($handle, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5]);
if ($body !== null) { curl_setopt($handle, CURLOPT_POSTFIELDS, $body); }
$response = curl_exec($handle);
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
if (!is_string($response) || $status < 200 || $status >= 300) { throw new \RuntimeException('Externer Dienst nicht erreichbar.'); }
} else {
$response = @file_get_contents($url, false, stream_context_create(['http' => ['method' => $method, 'header' => implode("\r\n", $headers), 'content' => $body ?? '', 'timeout' => 10, 'ignore_errors' => true]]));
if (!is_string($response)) { throw new \RuntimeException('Externer Dienst nicht erreichbar.'); }
}
$decoded = json_decode($response, true);
if (!is_array($decoded)) { throw new \RuntimeException('Ungültige Antwort des externen Dienstes.'); }
return $decoded;
}
/** Nominatim requires cached requests and a global maximum of one request per second. */
private function cachedOsmRequest(string $url): array
{
$directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'papa-kind-treff-osm';
if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) {
return $this->requestJson('GET', $url, null, ['Accept-Language: de']);
}
$cacheFile = $directory . DIRECTORY_SEPARATOR . hash('sha256', $url) . '.json';
if (is_file($cacheFile) && filemtime($cacheFile) >= time() - 900) {
$cached = json_decode((string)file_get_contents($cacheFile), true);
if (is_array($cached)) {
return $cached;
}
}
$lock = fopen($directory . DIRECTORY_SEPARATOR . 'request.lock', 'c+');
if ($lock === false) {
return $this->requestJson('GET', $url, null, ['Accept-Language: de']);
}
try {
flock($lock, LOCK_EX);
clearstatcache(true, $cacheFile);
if (is_file($cacheFile) && filemtime($cacheFile) >= time() - 900) {
$cached = json_decode((string)file_get_contents($cacheFile), true);
if (is_array($cached)) {
return $cached;
}
}
rewind($lock);
$lastRequestAt = (float)trim((string)stream_get_contents($lock));
$waitMicros = (int)max(0, (1.0 - (microtime(true) - $lastRequestAt)) * 1000000);
if ($waitMicros > 0) {
usleep($waitMicros);
}
$rows = $this->requestJson('GET', $url, null, ['Accept-Language: de']);
file_put_contents($cacheFile, json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
ftruncate($lock, 0);
rewind($lock);
fwrite($lock, (string)microtime(true));
fflush($lock);
return $rows;
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
private function distance(float $lat1, float $lng1, float $lat2, float $lng2): int
{
$earthRadius = 6371000.0;
$dLat = deg2rad($lat2 - $lat1); $dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2;
return (int)round($earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a)));
}
}

View File

@@ -3,6 +3,9 @@ declare(strict_types=1);
namespace App;
use App\Avatar\AvatarManager;
use App\Avatar\Lorelei;
final class ProfileSettings
{
private bool $schemaEnsured = false;
@@ -18,6 +21,8 @@ final class ProfileSettings
return;
}
$this->ensureSensitiveProfileColumns();
if (!$this->hasColumn('user_profiles', 'location_tracking_preference')) {
$this->pdo->exec(
"ALTER TABLE user_profiles
@@ -28,9 +33,61 @@ final class ProfileSettings
$this->columnCache['user_profiles.location_tracking_preference'] = true;
}
$avatarColumns = [
'avatar_style' => "ALTER TABLE user_profiles ADD COLUMN avatar_style VARCHAR(40) NOT NULL DEFAULT 'lorelei' AFTER location_tracking_preference",
'avatar_seed' => "ALTER TABLE user_profiles ADD COLUMN avatar_seed VARCHAR(100) NOT NULL DEFAULT '' AFTER avatar_style",
'avatar_config_json' => "ALTER TABLE user_profiles ADD COLUMN avatar_config_json TEXT NULL AFTER avatar_seed",
'avatar_preset' => "ALTER TABLE user_profiles ADD COLUMN avatar_preset VARCHAR(64) NOT NULL DEFAULT 'papa-kind-treff' AFTER location_tracking_preference",
'avatar_lorelei_eyes_variant' => "ALTER TABLE user_profiles ADD COLUMN avatar_lorelei_eyes_variant VARCHAR(24) NOT NULL DEFAULT '' AFTER avatar_preset",
'avatar_lorelei_eyebrows_variant' => "ALTER TABLE user_profiles ADD COLUMN avatar_lorelei_eyebrows_variant VARCHAR(24) NOT NULL DEFAULT '' AFTER avatar_lorelei_eyes_variant",
'avatar_lorelei_mouth_variant' => "ALTER TABLE user_profiles ADD COLUMN avatar_lorelei_mouth_variant VARCHAR(24) NOT NULL DEFAULT '' AFTER avatar_lorelei_eyebrows_variant",
'avatar_lorelei_glasses_variant' => "ALTER TABLE user_profiles ADD COLUMN avatar_lorelei_glasses_variant VARCHAR(24) NOT NULL DEFAULT '' AFTER avatar_lorelei_mouth_variant",
'avatar_lorelei_hair_variant' => "ALTER TABLE user_profiles ADD COLUMN avatar_lorelei_hair_variant VARCHAR(24) NOT NULL DEFAULT '' AFTER avatar_lorelei_glasses_variant",
'avatar_lorelei_beard_variant' => "ALTER TABLE user_profiles ADD COLUMN avatar_lorelei_beard_variant VARCHAR(24) NOT NULL DEFAULT '' AFTER avatar_lorelei_hair_variant",
'avatar_lorelei_earrings_variant' => "ALTER TABLE user_profiles ADD COLUMN avatar_lorelei_earrings_variant VARCHAR(24) NOT NULL DEFAULT '' AFTER avatar_lorelei_beard_variant",
];
foreach ($avatarColumns as $column => $sql) {
if (!$this->hasColumn('user_profiles', $column)) {
$this->pdo->exec($sql);
$this->columnCache['user_profiles.' . $column] = true;
}
}
$this->schemaEnsured = true;
}
private function ensureSensitiveProfileColumns(): void
{
$columnDefinitions = [
'first_name' => 'VARBINARY(512) NULL',
'last_name' => 'VARBINARY(512) NULL',
'street' => 'VARBINARY(512) NULL',
'contact_phone' => 'VARBINARY(512) NULL',
'contact_email' => 'VARBINARY(512) NULL',
'profession' => 'VARBINARY(512) NULL',
'languages' => 'VARBINARY(1024) NULL',
'about' => 'VARBINARY(2048) NULL',
];
foreach ($columnDefinitions as $column => $definition) {
if (!$this->hasColumn('user_profiles', $column)) {
if ($column === 'street') {
$this->pdo->exec('ALTER TABLE user_profiles ADD COLUMN street VARBINARY(512) NULL AFTER last_name');
$this->columnCache['user_profiles.street'] = true;
$this->columnTypeCache['user_profiles.street'] = 'varbinary';
}
continue;
}
$dataType = $this->getColumnDataType('user_profiles', $column);
if ($dataType !== 'varbinary') {
$this->pdo->exec(sprintf('ALTER TABLE user_profiles MODIFY COLUMN %s %s', $column, $definition));
$this->columnCache['user_profiles.' . $column] = true;
$this->columnTypeCache['user_profiles.' . $column] = 'varbinary';
}
}
}
public function getLocationTrackingPreference(int $userId): string
{
if ($userId <= 0) {
@@ -59,6 +116,49 @@ final class ProfileSettings
]);
}
public function updateAvatar(int $userId, array $avatarConfig): void
{
if ($userId <= 0) {
return;
}
$this->ensureSchema();
$avatar = AvatarManager::normalizeSubmission($avatarConfig, $userId);
$legacy = $avatar['avatar_style'] === Lorelei::key()
? Lorelei::legacyColumnsFromConfig($avatar['avatar_seed'], $avatar['avatar_config_array'])
: Lorelei::legacyColumnsFromConfig($avatar['avatar_seed'], Lorelei::defaultConfig());
$stmt = $this->pdo->prepare(
'UPDATE user_profiles
SET avatar_style = :avatarStyle,
avatar_seed = :avatarSeed,
avatar_config_json = :avatarConfigJson,
avatar_preset = :preset,
avatar_lorelei_eyes_variant = :eyesVariant,
avatar_lorelei_eyebrows_variant = :eyebrowsVariant,
avatar_lorelei_mouth_variant = :mouthVariant,
avatar_lorelei_glasses_variant = :glassesVariant,
avatar_lorelei_hair_variant = :hairVariant,
avatar_lorelei_beard_variant = :beardVariant,
avatar_lorelei_earrings_variant = :earringsVariant,
updated_at = NOW()
WHERE user_id = :id'
);
$stmt->execute([
'avatarStyle' => $avatar['avatar_style'],
'avatarSeed' => $avatar['avatar_seed'],
'avatarConfigJson' => $avatar['avatar_config_json'],
'preset' => $legacy['avatar_preset'],
'eyesVariant' => $legacy['avatar_lorelei_eyes_variant'],
'eyebrowsVariant' => $legacy['avatar_lorelei_eyebrows_variant'],
'mouthVariant' => $legacy['avatar_lorelei_mouth_variant'],
'glassesVariant' => $legacy['avatar_lorelei_glasses_variant'],
'hairVariant' => $legacy['avatar_lorelei_hair_variant'],
'beardVariant' => $legacy['avatar_lorelei_beard_variant'],
'earringsVariant' => $legacy['avatar_lorelei_earrings_variant'],
'id' => $userId,
]);
}
private function hasColumn(string $table, string $column): bool
{
$cacheKey = $table . '.' . $column;
@@ -80,4 +180,30 @@ final class ProfileSettings
return $this->columnCache[$cacheKey] = ((int)$stmt->fetchColumn() > 0);
}
private array $columnTypeCache = [];
private function getColumnDataType(string $table, string $column): ?string
{
$cacheKey = $table . '.' . $column;
if (array_key_exists($cacheKey, $this->columnTypeCache)) {
return $this->columnTypeCache[$cacheKey];
}
$stmt = $this->pdo->prepare("
SELECT DATA_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :tableName
AND COLUMN_NAME = :columnName
LIMIT 1
");
$stmt->execute([
'tableName' => $table,
'columnName' => $column,
]);
$value = $stmt->fetchColumn();
return $this->columnTypeCache[$cacheKey] = $value !== false ? strtolower((string)$value) : null;
}
}

110
src/App/SystemSettings.php Normal file
View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App;
final class SystemSettings
{
private array $tableCache = [];
private const DEFAULTS = [
'google_places_enabled' => '0',
'azure_maps_enabled' => '0',
'osm_places_enabled' => '1',
'forum_maintenance_mode' => '0',
'site_maintenance_mode' => '0',
'site_maintenance_message' => 'Papa-Kind-Treff ist gerade kurz in Wartung. Bitte versuche es in Kürze erneut.',
'place_data_provider' => 'osm',
'community_levels_json' => '',
];
public function __construct(private \PDO $pdo)
{
}
public function ensureSchema(): void
{
$this->pdo->exec(
'CREATE TABLE IF NOT EXISTS system_settings (
`key` VARCHAR(120) NOT NULL PRIMARY KEY,
`value` TEXT NULL,
updated_by BIGINT UNSIGNED NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_system_settings_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
$stmt = $this->pdo->prepare(
'INSERT INTO system_settings (`key`, `value`, updated_by)
VALUES (:key, :value, NULL)
ON DUPLICATE KEY UPDATE `value` = `value`'
);
foreach (self::DEFAULTS as $key => $value) {
$stmt->execute([
'key' => $key,
'value' => $value,
]);
}
}
public function getAll(): array
{
$this->ensureSchema();
$settings = self::DEFAULTS;
$stmt = $this->pdo->query('SELECT `key`, `value` FROM system_settings');
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
$settings[(string)$row['key']] = (string)($row['value'] ?? '');
}
return $settings;
}
public function get(string $key, ?string $default = null): ?string
{
$this->ensureSchema();
$stmt = $this->pdo->prepare('SELECT `value` FROM system_settings WHERE `key` = :key LIMIT 1');
$stmt->execute(['key' => $key]);
$value = $stmt->fetchColumn();
if ($value === false) {
return $default ?? (self::DEFAULTS[$key] ?? null);
}
return (string)$value;
}
public function getBool(string $key, bool $default = false): bool
{
$value = $this->get($key, $default ? '1' : '0');
return in_array((string)$value, ['1', 'true', 'yes', 'on'], true);
}
public function set(string $key, string $value, ?int $updatedBy = null): void
{
$this->ensureSchema();
$stmt = $this->pdo->prepare(
'INSERT INTO system_settings (`key`, `value`, updated_by)
VALUES (:key, :value, :updatedBy)
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), updated_by = VALUES(updated_by), updated_at = CURRENT_TIMESTAMP'
);
$stmt->execute([
'key' => $key,
'value' => $value,
'updatedBy' => $updatedBy,
]);
}
public function updateMany(array $values, ?int $updatedBy = null): void
{
$this->ensureSchema();
$stmt = $this->pdo->prepare(
'INSERT INTO system_settings (`key`, `value`, updated_by)
VALUES (:key, :value, :updatedBy)
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), updated_by = VALUES(updated_by), updated_at = CURRENT_TIMESTAMP'
);
foreach ($values as $key => $value) {
$stmt->execute([
'key' => (string)$key,
'value' => (string)$value,
'updatedBy' => $updatedBy,
]);
}
}
}

247
src/App/UserEmailStore.php Normal file
View File

@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace App;
final class UserEmailStore
{
private bool $schemaEnsured = false;
private ?Crypto $crypto = null;
private string $lookupKey;
public function __construct(private \PDO $pdo)
{
$configDir = dirname(__DIR__, 2) . '/config';
$this->crypto = new Crypto(Config::fromPhpConstants($configDir));
$this->lookupKey = $this->buildLookupKey();
}
public function ensureSchema(): void
{
if ($this->schemaEnsured) {
return;
}
if (!$this->hasColumn('users', 'email_lookup_hash')) {
$this->pdo->exec('ALTER TABLE users ADD COLUMN email_lookup_hash CHAR(64) NULL AFTER email');
}
if ($this->getColumnDataType('users', 'email') !== 'varbinary') {
$this->pdo->exec('ALTER TABLE users MODIFY COLUMN email VARBINARY(512) NOT NULL');
}
$this->migrateEmails();
$this->dropUniqueIndexOnEmailColumn();
$this->ensureLookupHashIndex();
$this->schemaEnsured = true;
}
public function normalize(string $email): string
{
return strtolower(trim($email));
}
public function lookupHash(string $email): string
{
return hash_hmac('sha256', $this->normalize($email), $this->lookupKey);
}
public function encrypt(string $email): string
{
return $this->crypto->encrypt($this->normalize($email));
}
public function decrypt(?string $value): string
{
$raw = (string)($value ?? '');
if ($raw === '') {
return '';
}
if (Crypto::looksEncrypted($raw)) {
return $this->crypto->decrypt($raw) ?: '';
}
return $this->normalize($raw);
}
public function decodeAndMigrateValue(?string $value, int $userId): string
{
$decoded = $this->decrypt($value);
$raw = (string)($value ?? '');
if ($decoded !== '' && !Crypto::looksEncrypted($raw)) {
$this->updateUserEmail($userId, $decoded);
}
return $decoded;
}
public function updateUserEmail(int $userId, string $email): void
{
$normalized = $this->normalize($email);
$stmt = $this->pdo->prepare('UPDATE users SET email = :email, email_lookup_hash = :lookup, updated_at = NOW() WHERE id = :id');
$stmt->execute([
'email' => $this->encrypt($normalized),
'lookup' => $this->lookupHash($normalized),
'id' => $userId,
]);
}
public function findUserByEmail(string $email, string $select = 'id, email, password_hash, status'): array|false
{
$this->ensureSchema();
$stmt = $this->pdo->prepare(sprintf('SELECT %s FROM users WHERE email_lookup_hash = :lookup LIMIT 1', $select));
$stmt->execute([
'lookup' => $this->lookupHash($email),
]);
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
public function getEmailByUserId(int $userId): string
{
$this->ensureSchema();
$stmt = $this->pdo->prepare('SELECT email FROM users WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $userId]);
$value = $stmt->fetchColumn();
return $this->decodeAndMigrateValue($value === false ? null : (string)$value, $userId);
}
public function decryptRowEmails(array $rows, string $idKey = 'id', string $emailKey = 'email'): array
{
foreach ($rows as &$row) {
if (!isset($row[$emailKey])) {
continue;
}
$row[$emailKey] = $this->decodeAndMigrateValue((string)$row[$emailKey], (int)($row[$idKey] ?? 0));
}
unset($row);
return $rows;
}
private function migrateEmails(): void
{
$stmt = $this->pdo->query('SELECT id, email, email_lookup_hash FROM users');
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
$emailValue = (string)($row['email'] ?? '');
if ($emailValue === '') {
continue;
}
$decoded = $this->decrypt($emailValue);
if ($decoded === '') {
continue;
}
$needsEmailUpdate = !Crypto::looksEncrypted($emailValue);
$lookupHash = (string)($row['email_lookup_hash'] ?? '');
$needsLookupUpdate = $lookupHash === '' || !hash_equals($lookupHash, $this->lookupHash($decoded));
if ($needsEmailUpdate || $needsLookupUpdate) {
$this->updateUserEmail((int)$row['id'], $decoded);
}
}
}
private function ensureLookupHashIndex(): void
{
$stmt = $this->pdo->prepare("
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'users'
AND index_name = 'uq_users_email_lookup_hash'
");
$stmt->execute();
if ((int)$stmt->fetchColumn() === 0) {
$this->pdo->exec('ALTER TABLE users ADD UNIQUE INDEX uq_users_email_lookup_hash (email_lookup_hash)');
}
}
private function dropUniqueIndexOnEmailColumn(): void
{
$stmt = $this->pdo->query("
SELECT index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'users'
AND column_name = 'email'
AND non_unique = 0
AND index_name <> 'PRIMARY'
");
foreach ($stmt->fetchAll(\PDO::FETCH_COLUMN) ?: [] as $indexName) {
$this->pdo->exec(sprintf('ALTER TABLE users DROP INDEX %s', $indexName));
}
}
private function hasColumn(string $table, string $column): bool
{
$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 (int)$stmt->fetchColumn() > 0;
}
private function getColumnDataType(string $table, string $column): ?string
{
$stmt = $this->pdo->prepare("
SELECT DATA_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :tableName
AND COLUMN_NAME = :columnName
LIMIT 1
");
$stmt->execute([
'tableName' => $table,
'columnName' => $column,
]);
$value = $stmt->fetchColumn();
return $value !== false ? strtolower((string)$value) : null;
}
private function buildLookupKey(): string
{
$raw = trim((string)(getenv('EMAIL_LOOKUP_KEY') ?: ''));
if ($raw !== '') {
$decoded = base64_decode(str_starts_with($raw, 'base64:') ? substr($raw, 7) : $raw, true);
if ($decoded !== false && $decoded !== '') {
return $decoded;
}
if (ctype_xdigit($raw) && strlen($raw) % 2 === 0) {
$hex = hex2bin($raw);
if ($hex !== false) {
return $hex;
}
}
return $raw;
}
$dataKey = trim((string)(getenv('DATA_KEY') ?: ''));
if ($dataKey === '') {
throw new \RuntimeException('EMAIL_LOOKUP_KEY oder DATA_KEY wird für sichere E-Mail-Speicherung benötigt.');
}
if (str_starts_with($dataKey, 'base64:')) {
$dataKey = substr($dataKey, 7);
}
$decoded = base64_decode($dataKey, true);
if ($decoded !== false && $decoded !== '') {
$dataKey = $decoded;
} elseif (ctype_xdigit($dataKey) && strlen($dataKey) % 2 === 0) {
$hex = hex2bin($dataKey);
if ($hex !== false) {
$dataKey = $hex;
}
}
return hash_hkdf('sha256', (string)$dataKey, 32, 'pkt-user-email-lookup');
}
}