avatar
All checks were successful
Deploy / deploy (push) Successful in 1m7s

This commit is contained in:
2026-07-28 02:03:01 +02:00
parent 0ac69fdfd5
commit aa86001760
18 changed files with 827 additions and 385 deletions

View File

@@ -1,13 +1,17 @@
Anweisung: Projektstruktur (Basis-Template) wie in „papa-kind-treff“ Anweisung: Projektstruktur (Basis-Template) wie in „papa-kind-treff“
Aktueller Projektstand Aktueller Projektstand
- Papa-Kind-Treff nutzt im Mitgliederbereich DiceBear `lorelei` über einen internen API-Proxy mit lokalem Cache. - Papa-Kind-Treff nutzt im Mitgliederbereich eine Mehr-Style-Avatar-Architektur auf Basis von DiceBear.
- Relevante Logik liegt in `src/App/Avatar/Lorelei.php`. - Die zentrale Avatar-Verwaltung liegt in `src/App/Avatar/AvatarManager.php`.
- Der aktuell aktive Style liegt in `src/App/Avatar/Lorelei.php`.
- Die Avatar-Konfiguration liegt in `config/avatar.php`.
- Der Avatar-Proxy liegt unter `public/api/avatar.php`. - Der Avatar-Proxy liegt unter `public/api/avatar.php`.
- Die visuelle Avatar-Konfiguration liegt im Dashboard-Modal unter `partials/landing/account/dashboard.php` und rendert Komponenten-Vorschauen live nach. - Die visuelle Avatar-Konfiguration liegt im Dashboard-Modal unter `partials/landing/account/dashboard.php` und rendert Komponenten-Vorschauen live nach.
- Der Generator besitzt einen Button `Random Profilbild`, der Seed und Varianten zufällig neu setzt. - Der Generator speichert `avatar_style`, `avatar_seed` und `avatar_config_json` generisch im Profil.
- Beim Oeffnen des Modals werden nur die Bilder des aktiven Tabs geladen; weitere Tabs laden ihre Varianten erst beim Wechsel nach. - Gespeicherte Benutzer-Avatare werden lokal unter `public/assets/avatars/users/ptk-<userId>.svg` abgelegt und bei Änderungen überschrieben.
- Alte statische Avatar-Presets und frühere Layer-Assets sind entfernt; aktiv ist nur noch der Lorelei-Generator. - Der Generator besitzt einen Button `Random Profilbild`, der Seed und Varianten des aktiven Styles zufällig neu setzt.
- Beim Oeffnen des Modals werden nur die Bilder des aktiven Styles und Tabs geladen; weitere Tabs laden ihre Varianten erst beim Wechsel nach.
- Alte statische Avatar-Presets und frühere Layer-Assets sind entfernt.
- Änderungen an Funktionen mit Cookies, LocalStorage, SessionStorage, Geolocation, Tracking oder Drittanbietern müssen immer auch in Datenschutz-/Cookie-Hinweisen und im Consent-Flow berücksichtigt werden. - Änderungen an Funktionen mit Cookies, LocalStorage, SessionStorage, Geolocation, Tracking oder Drittanbietern müssen immer auch in Datenschutz-/Cookie-Hinweisen und im Consent-Flow berücksichtigt werden.
Ziel Ziel

View File

@@ -11,8 +11,10 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
- Geschäftslogik unter `src/App/` - Geschäftslogik unter `src/App/`
- MySQL/MariaDB-Schema in `schema.sql` - MySQL/MariaDB-Schema in `schema.sql`
- Globales Frontend vor allem über `public/assets/js/app.js` und `public/assets/css/app.css` - Globales Frontend vor allem über `public/assets/js/app.js` und `public/assets/css/app.css`
- Avatar-Style `Lorelei` liegt getrennt unter `src/App/Avatar/Lorelei.php` - Avatar-Manager liegt zentral unter `src/App/Avatar/AvatarManager.php`
- Der Avatar-Endpunkt ist als direkte Datei unter `public/api/avatar.php` angebunden und laeuft bewusst ohne App-Bootstrap. - Der aktive Style `Lorelei` liegt getrennt unter `src/App/Avatar/Lorelei.php`
- Die Avatar-Konfiguration liegt in `config/avatar.php`
- Der Avatar-Endpunkt ist als direkte Datei unter `public/api/avatar.php` angebunden und laeuft bewusst ohne vollen App-Bootstrap.
## Wichtige Produktentscheidungen ## Wichtige Produktentscheidungen
- Die Ansprache im Frontend soll immer den aktuellen Nutzer direkt ansprechen. - Die Ansprache im Frontend soll immer den aktuellen Nutzer direkt ansprechen.
@@ -21,17 +23,17 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
- Die Community ist hierarchisch aufgebaut: Kategorien -> Boards -> Threads -> Posts. - Die Community ist hierarchisch aufgebaut: Kategorien -> Boards -> Threads -> Posts.
- Moderation ist getrennt vom normalen Community-Frontend in einem eigenen Adminbereich. - Moderation ist getrennt vom normalen Community-Frontend in einem eigenen Adminbereich.
- Standortfunktionen sind relevant für lokale Treffen und werden künftig weiter ausgebaut. - Standortfunktionen sind relevant für lokale Treffen und werden künftig weiter ausgebaut.
- Profilbilder werden aktuell über DiceBear im Stil `lorelei` serverseitig erzeugt und lokal gecacht. - Profilbilder werden aktuell über die Mehr-Style-Architektur serverseitig erzeugt; aktiv ist derzeit `lorelei`.
- Der Avatar-Editor bietet aktuell visuelle Komponenten-Vorschauen statt reiner Text-Dropdowns. - Stil, Seed und Varianten werden generisch im Profil gespeichert; das fertige SVG wird lokal pro Benutzer unter `ptk-<userId>.svg` abgelegt.
- Frühere statische Preset-Avatar-Assets wurden entfernt; der aktuelle Stand nutzt nur noch den Generator mit Proxy/Cache. - Der Avatar-Editor bietet visuelle Komponenten-Vorschauen statt reiner Text-Dropdowns und laedt nur den aktiven Style/Tab nach.
## Navigation / Konto ## Navigation / Konto
- Eingeloggte Nutzer sehen rechts ein Profil-Menü statt einzelner `Dashboard`-/`Logout`-Buttons. - Eingeloggte Nutzer sehen rechts ein Profil-Menü statt einzelner `Dashboard`-/`Logout`-Buttons.
- Das Profil-Menü enthält aktuell `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen`, `Abmelden`. - Das Profil-Menü enthält aktuell `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen`, `Abmelden`.
- Der Mitgliederbereich selbst ist als Layout mit linker Bereichsnavigation aufgebaut. - Der Mitgliederbereich selbst ist als Layout mit linker Bereichsnavigation aufgebaut.
- Im Profil kann der Nutzer aktuell Komponenten wie Augen, Augenbrauen, Mund, Brille, Haare, Bart und Ohrringe direkt per Vorschaubild auswählen. - Im Profil kann der Nutzer aktuell Komponenten wie Augen, Augenbrauen, Mund, Brille, Haare, Bart und Ohrringe direkt per Vorschaubild auswählen.
- Der Button `Random Profilbild` setzt Seed und Varianten zufaellig neu und aktualisiert die Vorschau sofort. - Der Button `Random Profilbild` setzt Seed und Varianten des aktiven Styles zufaellig neu und aktualisiert die Vorschau sofort.
- Beim Oeffnen des Avatar-Generators wird nur der aktive Komponenten-Tab geladen; weitere Varianten werden erst beim Tabwechsel nachgeladen. - Beim Oeffnen des Avatar-Generators wird nur der aktive Style-/Komponenten-Tab geladen; weitere Varianten werden erst beim Wechsel nachgeladen.
- Debug ist kein Menüpunkt und erscheint nur als Floating-Käfer für `site_admin` bei aktivem Debug-Modus. - Debug ist kein Menüpunkt und erscheint nur als Floating-Käfer für `site_admin` bei aktivem Debug-Modus.
## Community und Rechte ## Community und Rechte
@@ -59,7 +61,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
## Doku-Regel ## Doku-Regel
Bei jeder künftigen Änderung müssen mindestens diese Dateien mitgeprüft werden: Bei jeder künftigen Änderung müssen mindestens diese Dateien mitgeprüft werden:
- `README.md` - `README.md`
- `.projectstructure.txt` - `.projektstructure.txt`
- `PROJECT_CONTEXT.md` - `PROJECT_CONTEXT.md`
Wenn Cookies, LocalStorage, SessionStorage, Tracking, Geolocation oder Drittanbieter neu hinzukommen oder geändert werden, müssen zusätzlich Consent und rechtliche Hinweise angepasst werden. Wenn Cookies, LocalStorage, SessionStorage, Tracking, Geolocation oder Drittanbieter neu hinzukommen oder geändert werden, müssen zusätzlich Consent und rechtliche Hinweise angepasst werden.

View File

@@ -17,9 +17,10 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
- Impressum, Datenschutz-&-Cookies-Seite und Über-uns - Impressum, Datenschutz-&-Cookies-Seite und Über-uns
- standortbasierte Sortierung für die neuesten Treffen - standortbasierte Sortierung für die neuesten Treffen
- eigener Einstellungsbereich für Standortfreigabe, Browser-Hinweise und Consent-Verwaltung - eigener Einstellungsbereich für Standortfreigabe, Browser-Hinweise und Consent-Verwaltung
- modernes Avatar-System auf Basis von DiceBear `lorelei`, serverseitig über einen lokalen Proxy mit Cache ausgeliefert - modernes Avatar-System mit zentraler Mehr-Style-Architektur auf Basis von DiceBear
- Profilbild-Generator mit visueller Komponenten-Auswahl für Augen, Augenbrauen, Mund, Brille, Haare, Bart und Ohrringe sowie einem `Random Profilbild`-Button - aktiver Avatar-Style aktuell: `lorelei`; weitere Styles können über `config/avatar.php` freigeschaltet werden
- keine statischen Avatar-Preset-Dateien mehr im Repository; Vorschau und Auswahl laufen vollständig über den Lorelei-Generator - Profilbild-Generator mit visueller Komponenten-Auswahl, generischem Style-/Seed-/Config-Speichermodell und `Random Profilbild`
- jedes gespeicherte Profilbild wird als lokale Benutzer-SVG unter `public/assets/avatars/users/ptk-<userId>.svg` abgelegt
- Consent-Manager für Analyse und externe Dienste - Consent-Manager für Analyse und externe Dienste
- Navigation aktuell mit `Home`, `Suche`, `Community` - Navigation aktuell mit `Home`, `Suche`, `Community`
- eingeloggte Nutzer sehen rechts ein Profil-Menü mit Direktlinks zu `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen` - eingeloggte Nutzer sehen rechts ein Profil-Menü mit Direktlinks zu `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen`
@@ -39,10 +40,12 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
- `owner` - `owner`
Die Rechteverteilung und Community-Logik sind in `src/App/CommunityAccess.php` abgebildet. Die Rechteverteilung und Community-Logik sind in `src/App/CommunityAccess.php` abgebildet.
Die Lorelei-Avatarlogik liegt in `src/App/Avatar/Lorelei.php`. Die zentrale Avatar-Verwaltung liegt in `src/App/Avatar/AvatarManager.php`.
Der aktuell aktive Style `Lorelei` liegt in `src/App/Avatar/Lorelei.php`.
Die Style-/Storage-Konfiguration liegt in `config/avatar.php`.
Der direkte Avatar-Endpunkt liegt in `public/api/avatar.php`. Der direkte Avatar-Endpunkt liegt in `public/api/avatar.php`.
Die visuelle Avatar-Auswahl sitzt im Dashboard-Modal in `partials/landing/account/dashboard.php`. Die visuelle Avatar-Auswahl sitzt im Dashboard-Modal in `partials/landing/account/dashboard.php`.
Beim Oeffnen des Generators werden nur die Varianten des aktuell aktiven Tabs geladen; weitere Tabs laden erst beim Wechsel nach. Beim Oeffnen des Generators werden nur die Varianten des aktuell aktiven Styles und Tabs geladen; weitere Varianten werden erst beim Wechsel nachgeladen.
## Consent und rechtlich relevante Integrationen ## Consent und rechtlich relevante Integrationen
Der aktuelle Code nutzt oder kann nutzen: Der aktuelle Code nutzt oder kann nutzen:
@@ -52,7 +55,7 @@ Der aktuelle Code nutzt oder kann nutzen:
- lokale Standortspeicherung via Cookie, `localStorage`, `sessionStorage` (`external_services`) - lokale Standortspeicherung via Cookie, `localStorage`, `sessionStorage` (`external_services`)
- Leaflet von `unpkg.com` (`external_services`) - Leaflet von `unpkg.com` (`external_services`)
- OpenStreetMap Nominatim (`external_services`) - OpenStreetMap Nominatim (`external_services`)
- DiceBear `lorelei`r serverseitig erzeugte Profilbilder - DiceBear fuer serverseitig erzeugte Profilbilder; das Ergebnis wird lokal als Benutzer-SVG gespeichert
Wichtig: Wichtig:
- Nicht notwendige Analyse- und Drittanbieterfunktionen dürfen erst nach Einwilligung aktiv werden. - Nicht notwendige Analyse- und Drittanbieterfunktionen dürfen erst nach Einwilligung aktiv werden.
@@ -61,7 +64,7 @@ Wichtig:
## Dokumentationspflicht bei Änderungen ## Dokumentationspflicht bei Änderungen
Bei jeder Änderung im Projekt immer mitprüfen und bei Bedarf aktualisieren: Bei jeder Änderung im Projekt immer mitprüfen und bei Bedarf aktualisieren:
- `README.md` - `README.md`
- `.projectstructure.txt` - `.projektstructure.txt`
- `PROJECT_CONTEXT.md` - `PROJECT_CONTEXT.md`
Zusätzlich bei neuen Cookies, Tracking-Mechanismen oder Drittanbietern: Zusätzlich bei neuen Cookies, Tracking-Mechanismen oder Drittanbietern:
@@ -72,5 +75,5 @@ Zusätzlich bei neuen Cookies, Tracking-Mechanismen oder Drittanbietern:
## Dateien für neue Chats ## Dateien für neue Chats
Für spätere Chats als Einstieg besonders wichtig: Für spätere Chats als Einstieg besonders wichtig:
- `README.md` - `README.md`
- `.projectstructure.txt` - `.projektstructure.txt`
- `PROJECT_CONTEXT.md` - `PROJECT_CONTEXT.md`

28
config/avatar.php Normal file
View File

@@ -0,0 +1,28 @@
<?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',
],
'styles' => [
'lorelei' => [
'label' => 'Lorelei',
'class' => \App\Avatar\Lorelei::class,
'fixed_colors' => [],
'color_controls' => [],
],
],
];

View File

@@ -3,6 +3,10 @@ $app = app();
$vm = \App\AccountPages::dashboard($app); $vm = \App\AccountPages::dashboard($app);
extract($vm, EXTR_OVERWRITE); extract($vm, EXTR_OVERWRITE);
$avatarPlaceholder = 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 12 12%22%3E%3Crect width=%2212%22 height=%2212%22 fill=%22%23f7efe6%22/%3E%3C/svg%3E'; $avatarPlaceholder = 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 12 12%22%3E%3Crect width=%2212%22 height=%2212%22 fill=%22%23f7efe6%22/%3E%3C/svg%3E';
$avatarStyleKeys = array_keys($avatarBuilder ?? []);
$currentAvatarStyle = (string)($profile['avatar_style'] ?? ($avatarStyleKeys[0] ?? 'lorelei'));
$currentAvatarComponents = $avatarBuilder[$currentAvatarStyle]['components'] ?? [];
$dashboardUserId = (int)($_SESSION['user_id'] ?? 0);
$editing = isset($editEvent) && $editEvent !== null; $editing = isset($editEvent) && $editEvent !== null;
$actionEvent = $editing ? 'event_update' : 'event_add'; $actionEvent = $editing ? 'event_update' : 'event_add';
$startVal = $editEvent ? date('Y-m-d\TH:i', strtotime((string)$editEvent['starts_at'])) : ''; $startVal = $editEvent ? date('Y-m-d\TH:i', strtotime((string)$editEvent['starts_at'])) : '';
@@ -64,11 +68,11 @@ $sectionLinks = [
<div class="account-panel__body"> <div class="account-panel__body">
<div class="profile-avatar-panel"> <div class="profile-avatar-panel">
<div class="profile-avatar-panel__preview"> <div class="profile-avatar-panel__preview">
<?= \App\Avatar\Lorelei::render($profile, $profile['display_name'] ?: 'Papa', 'xl') ?> <?= \App\Avatar\AvatarManager::render($profile, $profile['display_name'] ?: 'Papa', 'xl', $dashboardUserId) ?>
</div> </div>
<div class="profile-avatar-panel__content"> <div class="profile-avatar-panel__content">
<h3>Dein Profilbild</h3> <h3>Dein Profilbild</h3>
<p class="muted">Dein Profilbild wird jetzt mit DiceBear im Stil Lorelei erzeugt. Du wählst eine Variante aus und speicherst deren Seed dauerhaft in deinem Profil.</p> <p class="muted">Dein Profilbild wird mit dem Avatar-Generator erzeugt und als feste SVG-Datei zu deinem Konto gespeichert. Stil, Seed und Varianten bleiben dabei in deinem Profil hinterlegt.</p>
<button class="btn" type="button" data-modal-open="modalAvatar">Profilbild gestalten</button> <button class="btn" type="button" data-modal-open="modalAvatar">Profilbild gestalten</button>
</div> </div>
</div> </div>
@@ -298,63 +302,80 @@ $sectionLinks = [
</div> </div>
<form method="post" class="stack gap-12 avatar-builder" data-avatar-builder style="margin-top:12px;"> <form method="post" class="stack gap-12 avatar-builder" data-avatar-builder style="margin-top:12px;">
<input type="hidden" name="action" value="avatar_update"> <input type="hidden" name="action" value="avatar_update">
<input type="hidden" name="avatar_preset" value="<?= htmlspecialchars((string)$profile['avatar_preset'], ENT_QUOTES) ?>" data-avatar-field="avatar_preset"> <?php if (count($avatarStyleKeys) <= 1): ?>
<input type="hidden" name="avatar_style" value="<?= htmlspecialchars($currentAvatarStyle, ENT_QUOTES) ?>" data-avatar-style-select>
<?php endif; ?>
<input type="hidden" name="avatar_seed" value="<?= htmlspecialchars((string)$profile['avatar_seed'], ENT_QUOTES) ?>" data-avatar-seed>
<div class="avatar-builder__layout"> <div class="avatar-builder__layout">
<div class="avatar-builder__preview"> <div class="avatar-builder__preview">
<div data-avatar-preview> <div data-avatar-preview>
<?= \App\Avatar\Lorelei::render($profile, $profile['display_name'] ?: 'Papa', 'xxl') ?> <?= \App\Avatar\AvatarManager::render($profile, $profile['display_name'] ?: 'Papa', 'xxl', $dashboardUserId) ?>
</div> </div>
<p class="muted small">Wähle eine Komponente und passe dein neues Profilbild Schritt für Schritt an. Gespeichert wird erst nach Klick auf den Button unten.</p> <p class="muted small">Wähle eine Komponente und passe dein neues Profilbild Schritt für Schritt an. Gespeichert wird erst nach Klick auf den Button unten.</p>
<?php if (count($avatarStyleKeys) > 1): ?>
<div class="stack gap-6" style="max-width:320px; margin:0 auto;">
<label class="label" for="avatarStyleSelect">Stil</label>
<select id="avatarStyleSelect" name="avatar_style" class="select" data-avatar-style-select>
<?php foreach ($avatarBuilder as $styleKey => $styleMeta): ?>
<option value="<?= htmlspecialchars($styleKey, ENT_QUOTES) ?>" <?= $styleKey === $currentAvatarStyle ? 'selected' : '' ?>>
<?= htmlspecialchars((string)$styleMeta['label'], ENT_QUOTES) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div class="flex gap-12" style="flex-wrap:wrap; justify-content:center;"> <div class="flex gap-12" style="flex-wrap:wrap; justify-content:center;">
<button class="btn ghost" type="button" data-avatar-random>Random Profilbild</button> <button class="btn ghost" type="button" data-avatar-random>Random Profilbild</button>
<button class="btn ghost" type="button" data-modal-close>Abbrechen</button> <button class="btn ghost" type="button" data-modal-close>Abbrechen</button>
<button class="btn" type="submit">Profilbild speichern</button> <button class="btn" type="submit">Profilbild speichern</button>
</div> </div>
<?php foreach ($avatarBuilder as $styleKey => $styleMeta): ?>
<?php $styleComponents = $styleMeta['components'] ?? []; ?>
<div class="avatar-style-group <?= $styleKey === $currentAvatarStyle ? 'is-active' : '' ?>" data-avatar-style-panel="<?= htmlspecialchars($styleKey, ENT_QUOTES) ?>" <?= $styleKey === $currentAvatarStyle ? '' : 'hidden' ?>>
<div class="avatar-component-nav" role="tablist" aria-label="Avatar-Komponenten"> <div class="avatar-component-nav" role="tablist" aria-label="Avatar-Komponenten">
<?php foreach (\App\Avatar\Lorelei::componentDefinitions() as $field => $meta): ?> <?php $firstField = array_key_first($styleComponents); ?>
<?php <?php foreach ($styleComponents as $field => $component): ?>
$componentOptions = $avatarOptions[$field] ?? [];
$componentCount = count(array_filter(array_keys($componentOptions), static fn ($value): bool => (string) $value !== ''));
?>
<button <button
type="button" type="button"
class="avatar-component-nav__button <?= $field === 'avatar_lorelei_eyes_variant' ? 'is-active' : '' ?>" class="avatar-component-nav__button <?= $field === $firstField ? 'is-active' : '' ?>"
data-avatar-style="<?= htmlspecialchars($styleKey, ENT_QUOTES) ?>"
data-avatar-component-tab="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>" data-avatar-component-tab="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"
aria-selected="<?= $field === 'avatar_lorelei_eyes_variant' ? 'true' : 'false' ?>" aria-selected="<?= $field === $firstField ? 'true' : 'false' ?>"
> >
<?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?> <?= htmlspecialchars((string)($component['label'] ?? $field), ENT_QUOTES) ?>
<span class="muted small">(<?= $componentCount ?>)</span> <span class="muted small">(<?= (int)($component['count'] ?? 0) ?>)</span>
</button> </button>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<div class="avatar-component-panels"> <div class="avatar-component-panels">
<?php foreach (\App\Avatar\Lorelei::componentDefinitions() as $field => $meta): ?> <?php foreach ($styleComponents as $field => $component): ?>
<section class="avatar-component-panel avatar-component-panel--<?= htmlspecialchars(str_replace(['avatar_lorelei_', '_variant'], '', (string)$field), ENT_QUOTES) ?> <?= $field === 'avatar_lorelei_eyes_variant' ? 'is-active' : '' ?>" data-avatar-component-panel="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"> <section class="avatar-component-panel avatar-component-panel--<?= htmlspecialchars((string)$field, ENT_QUOTES) ?> <?= $field === $firstField ? 'is-active' : '' ?>" data-avatar-style="<?= htmlspecialchars($styleKey, ENT_QUOTES) ?>" data-avatar-component-panel="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>">
<div class="avatar-component-panel__head"> <div class="avatar-component-panel__head">
<strong><?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?></strong> <strong><?= htmlspecialchars((string)($component['label'] ?? $field), ENT_QUOTES) ?></strong>
<span class="muted small">Gesamtes Profilbild zur sicheren Vorschau</span> <span class="muted small">Gesamtes Profilbild zur sicheren Vorschau</span>
</div> </div>
<div class="avatar-component-grid"> <div class="avatar-component-grid">
<?php foreach (\App\Avatar\Lorelei::previewChoices($profile, $field) as $choice): ?> <?php foreach (($component['options'] ?? []) as $choice): ?>
<label class="avatar-component-card"> <label class="avatar-component-card">
<input <input
type="radio" type="radio"
name="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>" name="avatar_config[<?= htmlspecialchars($styleKey, ENT_QUOTES) ?>][<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>]"
value="<?= htmlspecialchars((string)$choice['value'], ENT_QUOTES) ?>" value="<?= htmlspecialchars((string)($choice['value'] ?? ''), ENT_QUOTES) ?>"
data-avatar-style="<?= htmlspecialchars($styleKey, ENT_QUOTES) ?>"
data-avatar-field="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>" data-avatar-field="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"
<?= $choice['checked'] ? 'checked' : '' ?> <?= !empty($choice['checked']) ? 'checked' : '' ?>
> >
<span class="avatar-component-card__thumb"> <span class="avatar-component-card__thumb">
<img <img
src="<?= htmlspecialchars($avatarPlaceholder, ENT_QUOTES) ?>" src="<?= htmlspecialchars($avatarPlaceholder, ENT_QUOTES) ?>"
alt="<?= htmlspecialchars((string)$choice['label'], ENT_QUOTES) ?>" alt="<?= htmlspecialchars((string)($choice['label'] ?? ''), ENT_QUOTES) ?>"
data-avatar-option-thumb data-avatar-option-thumb
data-avatar-option-src="<?= htmlspecialchars((string)$choice['image'], ENT_QUOTES) ?>" data-avatar-style="<?= htmlspecialchars($styleKey, ENT_QUOTES) ?>"
data-avatar-option-field="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>" data-avatar-option-field="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"
data-avatar-option-value="<?= htmlspecialchars((string)$choice['value'], ENT_QUOTES) ?>" data-avatar-option-value="<?= htmlspecialchars((string)($choice['value'] ?? ''), ENT_QUOTES) ?>"
> >
</span> </span>
<span class="avatar-component-card__label"><?= htmlspecialchars((string)$choice['label'], ENT_QUOTES) ?></span> <span class="avatar-component-card__label"><?= htmlspecialchars((string)($choice['label'] ?? ''), ENT_QUOTES) ?></span>
</label> </label>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
@@ -362,6 +383,8 @@ $sectionLinks = [
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
<?php endforeach; ?>
</div>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -115,7 +115,7 @@ $threads = $community
?> ?>
<article class="forum-row"> <article class="forum-row">
<div class="forum-row__topic"> <div class="forum-row__topic">
<div class="forum-row__avatar"><?= \App\Avatar\Lorelei::render($t, (string)($t['display_name'] ?: 'Mitglied'), 'forum-list') ?></div> <div class="forum-row__avatar"><?= \App\Avatar\AvatarManager::render($t, (string)($t['display_name'] ?: 'Mitglied'), 'forum-list', (int)($t['uid'] ?? 0)) ?></div>
<div> <div>
<h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3> <h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3>
<div class="forum-row__meta"> <div class="forum-row__meta">
@@ -206,7 +206,7 @@ $threads = $community
?> ?>
<article class="forum-row"> <article class="forum-row">
<div class="forum-row__topic"> <div class="forum-row__topic">
<div class="forum-row__avatar"><?= \App\Avatar\Lorelei::render($t, (string)($t['display_name'] ?: 'Mitglied'), 'forum-list') ?></div> <div class="forum-row__avatar"><?= \App\Avatar\AvatarManager::render($t, (string)($t['display_name'] ?: 'Mitglied'), 'forum-list', (int)($t['uid'] ?? 0)) ?></div>
<div> <div>
<h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3> <h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3>
<div class="forum-row__meta"> <div class="forum-row__meta">

View File

@@ -154,7 +154,7 @@ $userVotes = ($access && $userId) ? $access->getUserPostVotes((int)$userId, $pos
<article class="forum-post forum-post--lead"> <article class="forum-post forum-post--lead">
<aside class="forum-post__author"> <aside class="forum-post__author">
<div class="forum-post__avatar"><?= \App\Avatar\Lorelei::render($thread, $threadAuthor, 'forum') ?></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> <strong><?= htmlspecialchars($threadAuthor, ENT_QUOTES) ?></strong>
<span><?= htmlspecialchars(trim((string)($threadLevel['icon'] ?? '') . ' ' . (string)($threadLevel['label'] ?? '')), ENT_QUOTES) ?></span> <span><?= htmlspecialchars(trim((string)($threadLevel['icon'] ?? '') . ' ' . (string)($threadLevel['label'] ?? '')), ENT_QUOTES) ?></span>
<span><?= number_format($threadPoints, 1) ?> Punkte</span> <span><?= number_format($threadPoints, 1) ?> Punkte</span>
@@ -203,7 +203,7 @@ $userVotes = ($access && $userId) ? $access->getUserPostVotes((int)$userId, $pos
?> ?>
<article class="forum-post<?= $isHighlighted ? ' forum-post--highlighted' : '' ?>"> <article class="forum-post<?= $isHighlighted ? ' forum-post--highlighted' : '' ?>">
<aside class="forum-post__author"> <aside class="forum-post__author">
<div class="forum-post__avatar"><?= \App\Avatar\Lorelei::render($p, $postAuthor, 'forum') ?></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> <strong><?= htmlspecialchars($postAuthor, ENT_QUOTES) ?></strong>
<?php if ($isHighlighted): ?> <?php if ($isHighlighted): ?>
<span class="forum-highlight-badge">Hilfreich hervorgehoben</span> <span class="forum-highlight-badge">Hilfreich hervorgehoben</span>

View File

@@ -11,7 +11,11 @@ if ($isLoggedIn) {
if ($pdo) { if ($pdo) {
$profileSettings = new \App\ProfileSettings($pdo); $profileSettings = new \App\ProfileSettings($pdo);
$profileSettings->ensureSchema(); $profileSettings->ensureSchema();
$stmt = $pdo->prepare('SELECT display_name, first_name, 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 FROM user_profiles WHERE user_id = :id LIMIT 1'); $avatarColumns = implode(', ', array_map(
static fn(string $column): string => $column,
\App\Avatar\AvatarManager::allProfileColumns()
));
$stmt = $pdo->prepare("SELECT user_id, display_name, first_name, $avatarColumns FROM user_profiles WHERE user_id = :id LIMIT 1");
$stmt->execute(['id' => (int)$_SESSION['user_id']]); $stmt->execute(['id' => (int)$_SESSION['user_id']]);
$profileRow = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; $profileRow = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
$displayName = trim((string)($profileRow['display_name'] ?? '')) ?: trim((string)($profileRow['first_name'] ?? '')) ?: 'Profil'; $displayName = trim((string)($profileRow['display_name'] ?? '')) ?: trim((string)($profileRow['first_name'] ?? '')) ?: 'Profil';
@@ -48,7 +52,7 @@ if ($isLoggedIn) {
<?php if ($isLoggedIn): ?> <?php if ($isLoggedIn): ?>
<div class="profile-menu" data-profile-menu> <div class="profile-menu" data-profile-menu>
<button class="profile-menu__trigger" type="button" aria-haspopup="menu" aria-expanded="false" data-profile-menu-trigger> <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\Lorelei::render($profileRow ?? [], $displayName, 'nav') ?></span> <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> <span class="profile-menu__name"><?= htmlspecialchars($displayName, ENT_QUOTES) ?></span>
</button> </button>
<div class="profile-menu__dropdown" role="menu"> <div class="profile-menu__dropdown" role="menu">

View File

@@ -1,61 +1,11 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/src/App/Avatar/Lorelei.php'; require_once dirname(__DIR__, 2) . '/config/fileload.php';
use App\Avatar\Lorelei; use App\Avatar\AvatarManager;
$allowedVariantParams = ['eyesVariant', 'eyebrowsVariant', 'mouthVariant', 'glassesVariant', 'hairVariant', 'beardVariant', 'earringsVariant']; $svg = AvatarManager::fetchSvgFromQuery($_GET);
$allowedProbabilityParams = ['glassesProbability', 'hairProbability', 'beardProbability', 'earringsProbability'];
$seed = Lorelei::normalizeSeed((string)($_GET['seed'] ?? 'papa-kind-treff'));
$cacheDir = dirname(__DIR__) . '/assets/avatars/dicebear-cache';
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0775, true);
}
$query = ['seed' => $seed, 'size' => '256'];
foreach ($allowedVariantParams as $param) {
$value = Lorelei::normalizeVariant((string)($_GET[$param] ?? ''));
if ($value !== '') {
$query[$param] = $value;
}
}
foreach ($allowedProbabilityParams as $param) {
$rawValue = (string)($_GET[$param] ?? '');
if ($rawValue === '') {
continue;
}
$value = max(0, min(100, (int) $rawValue));
$query[$param] = (string) $value;
}
$cacheKey = sha1(json_encode($query, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: $seed);
$cacheFile = $cacheDir . '/' . $cacheKey . '.svg';
$svg = is_file($cacheFile) ? file_get_contents($cacheFile) : false;
if ($svg === false) {
$url = 'https://api.dicebear.com/10.x/lorelei/svg?' . http_build_query($query);
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 2,
'header' => "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')) {
$svg = $remote;
@file_put_contents($cacheFile, $svg);
}
}
if (!is_string($svg) || $svg === '') {
$svg = Lorelei::fallbackSvg($seed, 'Avatar');
}
header('Content-Type: image/svg+xml; charset=utf-8'); header('Content-Type: image/svg+xml; charset=utf-8');
header('Cache-Control: public, max-age=604800, immutable'); header('Cache-Control: public, max-age=604800, immutable');

View File

@@ -726,51 +726,73 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
document.addEventListener('pkt:open-consent', openConsentModal); document.addEventListener('pkt:open-consent', openConsentModal);
const readAvatarValue = (form, field) => { const avatarStyleConfigs = {
const checked = form.querySelector(`[data-avatar-field="${field}"]:checked`); lorelei: {
fields: ['eyesVariant', 'eyebrowsVariant', 'mouthVariant', 'glassesVariant', 'hairVariant', 'beardVariant', 'earringsVariant'],
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',
},
},
};
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; if (checked) return checked.value;
const input = form.querySelector(`[data-avatar-field="${field}"]`); const input = panel.querySelector(`[data-avatar-field="${field}"]`);
return input ? input.value : ''; return input ? input.value : '';
}; };
const avatarComponentFieldMap = { const getAvatarStyleConfig = (style) => {
avatar_lorelei_eyes_variant: 'eyesVariant', if (avatarStyleConfigs[style]) return avatarStyleConfigs[style];
avatar_lorelei_eyebrows_variant: 'eyebrowsVariant', return avatarStyleConfigs.lorelei;
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',
}; };
const avatarComponentFields = Object.keys(avatarComponentFieldMap);
const avatarEndpoint = '/api/avatar.php'; const avatarEndpoint = '/api/avatar.php';
const avatarOptionalProbabilityMap = {
avatar_lorelei_glasses_variant: 'glassesProbability',
avatar_lorelei_beard_variant: 'beardProbability',
avatar_lorelei_earrings_variant: 'earringsProbability',
};
const buildAvatarParams = (form, overrides = {}) => { const buildAvatarParams = (form, style = getCurrentAvatarStyle(form), overrides = {}) => {
const seed = overrides.avatar_preset || readAvatarValue(form, 'avatar_preset'); const styleConfig = getAvatarStyleConfig(style);
const seed = overrides.avatar_seed || getAvatarSeedInput(form)?.value || '';
if (!seed) return null; if (!seed) return null;
const params = new URLSearchParams({ seed }); const params = new URLSearchParams({ style, seed });
const hairValue = Object.prototype.hasOwnProperty.call(overrides, 'avatar_lorelei_hair_variant') styleConfig.fields.forEach((field) => {
? overrides.avatar_lorelei_hair_variant
: readAvatarValue(form, 'avatar_lorelei_hair_variant');
params.set('hairProbability', '100');
if (hairValue) {
params.set('hairVariant', hairValue);
}
avatarComponentFields.forEach((field) => {
if (field === 'avatar_lorelei_hair_variant') return;
const value = Object.prototype.hasOwnProperty.call(overrides, field) const value = Object.prototype.hasOwnProperty.call(overrides, field)
? overrides[field] ? overrides[field]
: readAvatarValue(form, field); : readAvatarValue(form, style, field);
const param = avatarComponentFieldMap[field]; const param = styleConfig.paramMap[field];
const probabilityParam = avatarOptionalProbabilityMap[field]; const probabilityParam = styleConfig.optionalProbabilityMap[field];
const alwaysProbabilityParam = styleConfig.alwaysProbabilityMap[field];
if (alwaysProbabilityParam) {
params.set(alwaysProbabilityParam, value ? '100' : '0');
}
if (probabilityParam) { if (probabilityParam) {
params.set(probabilityParam, value ? '100' : '0'); params.set(probabilityParam, value ? '100' : '0');
@@ -788,7 +810,8 @@ document.addEventListener('DOMContentLoaded', () => {
const image = preview.querySelector('.pkt-avatar__img'); const image = preview.querySelector('.pkt-avatar__img');
if (!image) return; if (!image) return;
const params = buildAvatarParams(form); const style = getCurrentAvatarStyle(form);
const params = buildAvatarParams(form, style);
if (!params) return; if (!params) return;
const url = `${avatarEndpoint}?${params.toString()}`; const url = `${avatarEndpoint}?${params.toString()}`;
@@ -796,20 +819,24 @@ document.addEventListener('DOMContentLoaded', () => {
image.src = url; image.src = url;
}; };
const getActiveAvatarField = (form) => { const getActiveAvatarField = (form, style) => {
const activeTab = form.querySelector('[data-avatar-component-tab].is-active'); const panel = getAvatarStylePanel(form, style);
return activeTab?.getAttribute('data-avatar-component-tab') || 'avatar_lorelei_eyes_variant'; 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, activeField = null) => { const updateAvatarOptionPreviews = (form, style, activeField = null) => {
const panel = getAvatarStylePanel(form, style);
if (!panel) return;
const selector = activeField const selector = activeField
? `[data-avatar-component-panel="${activeField}"] [data-avatar-option-thumb]` ? `[data-avatar-component-panel="${activeField}"] [data-avatar-option-thumb]`
: '[data-avatar-option-thumb]'; : '[data-avatar-option-thumb]';
form.querySelectorAll(selector).forEach((image) => { panel.querySelectorAll(selector).forEach((image) => {
const field = image.getAttribute('data-avatar-option-field') || ''; const field = image.getAttribute('data-avatar-option-field') || '';
const value = image.getAttribute('data-avatar-option-value') || ''; const value = image.getAttribute('data-avatar-option-value') || '';
const params = buildAvatarParams(form, { [field]: value }); const params = buildAvatarParams(form, style, { [field]: value });
if (!params) return; if (!params) return;
const src = `${avatarEndpoint}?${params.toString()}`; const src = `${avatarEndpoint}?${params.toString()}`;
@@ -819,52 +846,83 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}; };
const ensureAvatarBuilderLoaded = (form) => { const ensureAvatarBuilderLoaded = (form, style = getCurrentAvatarStyle(form)) => {
if (form.dataset.avatarInitialized === 'true') return; const loadedStyles = new Set((form.dataset.avatarInitializedStyles || '').split(',').filter(Boolean));
form.dataset.avatarInitialized = 'true'; if (loadedStyles.has(style)) return;
loadedStyles.add(style);
form.dataset.avatarInitializedStyles = Array.from(loadedStyles).join(',');
updateAvatarPreview(form); updateAvatarPreview(form);
updateAvatarOptionPreviews(form, getActiveAvatarField(form)); updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
}; };
document.querySelectorAll('[data-avatar-builder]').forEach((form) => { document.querySelectorAll('[data-avatar-builder]').forEach((form) => {
form.querySelectorAll('[data-avatar-field]').forEach((field) => { form.querySelectorAll('[data-avatar-field]').forEach((field) => {
field.addEventListener('change', () => { field.addEventListener('change', () => {
ensureAvatarBuilderLoaded(form); const style = field.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
ensureAvatarBuilderLoaded(form, style);
updateAvatarPreview(form); updateAvatarPreview(form);
updateAvatarOptionPreviews(form, getActiveAvatarField(form)); updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
}); });
field.addEventListener('input', () => { field.addEventListener('input', () => {
ensureAvatarBuilderLoaded(form); const style = field.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
ensureAvatarBuilderLoaded(form, style);
updateAvatarPreview(form); updateAvatarPreview(form);
updateAvatarOptionPreviews(form, getActiveAvatarField(form)); updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
}); });
}); });
const setActiveComponentPanel = (field) => { const setActiveComponentPanel = (style, field) => {
form.querySelectorAll('[data-avatar-component-tab]').forEach((button) => { 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; const active = button.getAttribute('data-avatar-component-tab') === field;
button.classList.toggle('is-active', active); button.classList.toggle('is-active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false'); button.setAttribute('aria-selected', active ? 'true' : 'false');
}); });
form.querySelectorAll('[data-avatar-component-panel]').forEach((panel) => { panel.querySelectorAll('[data-avatar-component-panel]').forEach((componentPanel) => {
panel.classList.toggle('is-active', panel.getAttribute('data-avatar-component-panel') === field); componentPanel.classList.toggle('is-active', componentPanel.getAttribute('data-avatar-component-panel') === field);
}); });
updateAvatarOptionPreviews(form, 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;
});
});
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) => { form.querySelectorAll('[data-avatar-component-tab]').forEach((button) => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
const style = button.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
const field = button.getAttribute('data-avatar-component-tab'); const field = button.getAttribute('data-avatar-component-tab');
if (field) { if (field) {
ensureAvatarBuilderLoaded(form); ensureAvatarBuilderLoaded(form, style);
setActiveComponentPanel(field); setActiveComponentPanel(style, field);
} }
}); });
}); });
avatarComponentFields.forEach((field) => { Object.values(avatarStyleConfigs).forEach((styleConfig) => {
form.querySelectorAll(`[data-avatar-field="${field}"]`).forEach((input) => { styleConfig.fields.forEach((fieldName) => {
input.addEventListener('change', () => setActiveComponentPanel(field)); form.querySelectorAll(`[data-avatar-field="${fieldName}"]`).forEach((input) => {
input.addEventListener('change', () => {
const style = input.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
setActiveComponentPanel(style, fieldName);
});
});
}); });
}); });
@@ -891,29 +949,36 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
form.querySelector('[data-avatar-random]')?.addEventListener('click', () => { form.querySelector('[data-avatar-random]')?.addEventListener('click', () => {
const seedField = form.querySelector('[data-avatar-field="avatar_preset"]'); const style = getCurrentAvatarStyle(form);
const styleConfig = getAvatarStyleConfig(style);
const seedField = getAvatarSeedInput(form);
if (!(seedField instanceof HTMLInputElement)) return; if (!(seedField instanceof HTMLInputElement)) return;
seedField.value = buildSeed(); seedField.value = buildSeed();
avatarComponentFields.forEach((field) => { styleConfig.fields.forEach((field) => {
const options = Array.from(form.querySelectorAll(`[data-avatar-field="${field}"]`)); const panel = getAvatarStylePanel(form, style);
if (!panel) return;
const options = Array.from(panel.querySelectorAll(`[data-avatar-field="${field}"]`));
if (!options.length) return; if (!options.length) return;
const randomOption = options[Math.floor(Math.random() * options.length)]; const randomOption = options[Math.floor(Math.random() * options.length)];
if (!(randomOption instanceof HTMLInputElement)) return; if (!(randomOption instanceof HTMLInputElement)) return;
randomOption.checked = true; randomOption.checked = true;
}); });
ensureAvatarBuilderLoaded(form); ensureAvatarBuilderLoaded(form, style);
updateAvatarPreview(form); updateAvatarPreview(form);
updateAvatarOptionPreviews(form, getActiveAvatarField(form)); updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
}); });
setActiveStyle(getCurrentAvatarStyle(form));
}); });
document.querySelectorAll('[data-modal-open="modalAvatar"]').forEach((button) => { document.querySelectorAll('[data-modal-open="modalAvatar"]').forEach((button) => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
const form = document.querySelector('#modalAvatar [data-avatar-builder]'); const form = document.querySelector('#modalAvatar [data-avatar-builder]');
if (!form) return; if (!form) return;
window.setTimeout(() => ensureAvatarBuilderLoaded(form), 20); const style = getCurrentAvatarStyle(form);
window.setTimeout(() => ensureAvatarBuilderLoaded(form, style), 20);
}); });
}); });

View File

@@ -1,60 +1,4 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use App\Avatar\Lorelei; require dirname(__DIR__, 2) . '/api/avatar.php';
$allowedVariantParams = ['eyesVariant', 'eyebrowsVariant', 'mouthVariant', 'glassesVariant', 'hairVariant', 'beardVariant', 'earringsVariant'];
$allowedProbabilityParams = ['glassesProbability', 'hairProbability', 'beardProbability', 'earringsProbability'];
$seed = Lorelei::normalizeSeed((string)($_GET['seed'] ?? 'papa-kind-treff'));
$cacheDir = dirname(__DIR__, 3) . '/assets/avatars/dicebear-cache';
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0775, true);
}
$query = ['seed' => $seed, 'size' => '256'];
foreach ($allowedVariantParams as $param) {
$value = Lorelei::normalizeVariant((string)($_GET[$param] ?? ''));
if ($value !== '') {
$query[$param] = $value;
}
}
foreach ($allowedProbabilityParams as $param) {
$rawValue = (string)($_GET[$param] ?? '');
if ($rawValue === '') {
continue;
}
$value = max(0, min(100, (int) $rawValue));
$query[$param] = (string) $value;
}
$cacheKey = sha1(json_encode($query, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: $seed);
$cacheFile = $cacheDir . '/' . $cacheKey . '.svg';
$svg = is_file($cacheFile) ? file_get_contents($cacheFile) : false;
if ($svg === false) {
$url = 'https://api.dicebear.com/10.x/lorelei/svg?' . http_build_query($query);
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 6,
'header' => "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')) {
$svg = $remote;
@file_put_contents($cacheFile, $svg);
}
}
if (!is_string($svg) || $svg === '') {
$svg = Lorelei::fallbackSvg($seed, 'Avatar');
}
header('Content-Type: image/svg+xml; charset=utf-8');
header('Cache-Control: public, max-age=604800, immutable');
echo $svg;

View File

@@ -151,9 +151,10 @@ $clientCookie = $config->cookiePrefix() . 'client';
<section> <section>
<h2>10. Profilbilder mit DiceBear</h2> <h2>10. Profilbilder mit DiceBear</h2>
<p> <p>
Profilbilder werden serverseitig über den Dienst DiceBear im Stil „Lorelei“ erzeugt. Dabei wird für die Profilbilder werden serverseitig über unsere Anwendung erzeugt. Dafür nutzt Papa-Kind-Treff aktuell DiceBear
Generierung nur der im Profil gespeicherte Avatar-Seed verwendet. Der Abruf erfolgt über unsere Anwendung als technische Quelle für freigeschaltete Avatar-Stile. Gespeichert werden Stil, Seed und die im Generator
und wird lokal zwischengespeichert, sodass dein Browser die Avatar-Grafiken nicht direkt bei DiceBear lädt. 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>
<p> <p>
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, soweit die Avatar-Erzeugung Teil des gewählten Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, soweit die Avatar-Erzeugung Teil des gewählten

View File

@@ -30,6 +30,9 @@ CREATE TABLE user_profiles (
lat DECIMAL(10,7) NULL, lat DECIMAL(10,7) NULL,
lng DECIMAL(10,7) NULL, lng DECIMAL(10,7) NULL,
location_tracking_preference ENUM('disabled','prompt','enabled') NOT NULL DEFAULT 'prompt', 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_preset VARCHAR(64) NOT NULL DEFAULT 'papa-kind-treff',
avatar_lorelei_eyes_variant VARCHAR(24) NOT NULL DEFAULT '', avatar_lorelei_eyes_variant VARCHAR(24) NOT NULL DEFAULT '',
avatar_lorelei_eyebrows_variant VARCHAR(24) NOT NULL DEFAULT '', avatar_lorelei_eyebrows_variant VARCHAR(24) NOT NULL DEFAULT '',

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App; namespace App;
use App\Avatar\AvatarManager;
use App\Avatar\Lorelei; use App\Avatar\Lorelei;
final class AccountPages final class AccountPages
@@ -340,6 +341,9 @@ final class AccountPages
'email' => '', 'email' => '',
'contact_phone' => '', 'contact_phone' => '',
'location_tracking_preference' => 'prompt', 'location_tracking_preference' => 'prompt',
'avatar_style' => AvatarManager::defaultStyle(),
'avatar_seed' => '',
'avatar_config_json' => '',
'avatar_preset' => 'papa-kind-treff', 'avatar_preset' => 'papa-kind-treff',
'avatar_lorelei_eyes_variant' => '', 'avatar_lorelei_eyes_variant' => '',
'avatar_lorelei_eyebrows_variant' => '', 'avatar_lorelei_eyebrows_variant' => '',
@@ -352,7 +356,11 @@ final class AccountPages
if ($profileSettings) { if ($profileSettings) {
$profileSettings->ensureSchema(); $profileSettings->ensureSchema();
} }
$stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone, p.location_tracking_preference, p.avatar_preset, p.avatar_lorelei_eyes_variant, p.avatar_lorelei_eyebrows_variant, p.avatar_lorelei_mouth_variant, p.avatar_lorelei_glasses_variant, p.avatar_lorelei_hair_variant, p.avatar_lorelei_beard_variant, p.avatar_lorelei_earrings_variant FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1'); $avatarColumns = implode(', ', array_map(
static fn(string $column): string => 'p.' . $column,
AvatarManager::allProfileColumns()
));
$stmt = $pdo?->prepare("SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone, p.location_tracking_preference, $avatarColumns FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1");
$stmt?->execute(['id' => $userId]); $stmt?->execute(['id' => $userId]);
$row = $stmt?->fetch(\PDO::FETCH_ASSOC); $row = $stmt?->fetch(\PDO::FETCH_ASSOC);
if ($row) { if ($row) {
@@ -361,7 +369,7 @@ final class AccountPages
$profile['contact_phone'] = $crypto->decrypt((string)$profile['contact_phone']) ?: ''; $profile['contact_phone'] = $crypto->decrypt((string)$profile['contact_phone']) ?: '';
} }
} }
$profile = array_merge($profile, Lorelei::normalize($profile)); $profile = AvatarManager::normalizeProfile($profile, $userId);
$children = []; $children = [];
$stmt = $pdo?->prepare('SELECT id, encrypted_first_name AS first_name, note, gender, birthdate, age_years FROM children WHERE user_id = :id ORDER BY id DESC'); $stmt = $pdo?->prepare('SELECT id, encrypted_first_name AS first_name, note, gender, birthdate, age_years FROM children WHERE user_id = :id ORDER BY id DESC');
@@ -418,7 +426,7 @@ final class AccountPages
if (!in_array($section, $allowedSections, true)) { if (!in_array($section, $allowedSections, true)) {
$section = 'profile'; $section = 'profile';
} }
$avatarOptions = Lorelei::options(); $avatarBuilder = AvatarManager::builderStyles($profile);
return compact( return compact(
'flash', 'flash',
@@ -435,7 +443,7 @@ final class AccountPages
'communityApplication', 'communityApplication',
'communityCanApply', 'communityCanApply',
'communityRestrictions', 'communityRestrictions',
'avatarOptions', 'avatarBuilder',
'section', 'section',
'allowedSections' 'allowedSections'
); );

View File

@@ -0,0 +1,369 @@
<?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_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_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 = [];
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,
];
}
$components[$field] = [
'field' => $field,
'label' => (string)($definition['label'] ?? $field),
'count' => count(array_filter($options, static fn(array $option): bool => $option['value'] !== '')),
'options' => $options,
];
}
$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)
);
}
}

View File

@@ -5,76 +5,87 @@ namespace App\Avatar;
final class Lorelei final class Lorelei
{ {
public static function defaults(): array 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 [ return [
'avatar_preset' => 'papa-kind-treff', 'eyesVariant' => '',
'avatar_lorelei_eyes_variant' => '', 'eyebrowsVariant' => '',
'avatar_lorelei_eyebrows_variant' => '', 'mouthVariant' => '',
'avatar_lorelei_mouth_variant' => '', 'glassesVariant' => '',
'avatar_lorelei_glasses_variant' => '', 'hairVariant' => '',
'avatar_lorelei_hair_variant' => '', 'beardVariant' => '',
'avatar_lorelei_beard_variant' => '', 'earringsVariant' => '',
'avatar_lorelei_earrings_variant' => '',
]; ];
} }
public static function options(): array public static function options(): array
{ {
return [ return [
'avatar_lorelei_eyes_variant' => self::variantOptions(24), 'eyesVariant' => self::variantOptions(24),
'avatar_lorelei_eyebrows_variant' => self::variantOptions(13), 'eyebrowsVariant' => self::variantOptions(13),
'avatar_lorelei_mouth_variant' => self::mouthVariantOptions(), 'mouthVariant' => self::mouthVariantOptions(),
'avatar_lorelei_glasses_variant' => ['' => 'Keine'] + self::variantOptions(5), 'glassesVariant' => ['' => 'Keine'] + self::variantOptions(5),
'avatar_lorelei_hair_variant' => ['' => 'Keine'] + self::variantOptions(48), 'hairVariant' => ['' => 'Keine'] + self::variantOptions(48),
'avatar_lorelei_beard_variant' => ['' => 'Keine'] + self::variantOptions(2), 'beardVariant' => ['' => 'Keine'] + self::variantOptions(2),
'avatar_lorelei_earrings_variant' => ['' => 'Keine'] + self::variantOptions(3), 'earringsVariant' => ['' => 'Keine'] + self::variantOptions(3),
]; ];
} }
public static function componentDefinitions(): array public static function componentDefinitions(): array
{ {
return [ return [
'avatar_lorelei_eyes_variant' => ['label' => 'Augen', 'auto' => false], 'eyesVariant' => ['label' => 'Augen'],
'avatar_lorelei_eyebrows_variant' => ['label' => 'Augenbrauen', 'auto' => false], 'eyebrowsVariant' => ['label' => 'Augenbrauen'],
'avatar_lorelei_mouth_variant' => ['label' => 'Mund', 'auto' => false], 'mouthVariant' => ['label' => 'Mund'],
'avatar_lorelei_glasses_variant' => ['label' => 'Brille', 'auto' => true], 'glassesVariant' => ['label' => 'Brille'],
'avatar_lorelei_hair_variant' => ['label' => 'Haare', 'auto' => true], 'hairVariant' => ['label' => 'Haare'],
'avatar_lorelei_beard_variant' => ['label' => 'Bart', 'auto' => true], 'beardVariant' => ['label' => 'Bart'],
'avatar_lorelei_earrings_variant' => ['label' => 'Ohrringe', 'auto' => true], 'earringsVariant' => ['label' => 'Ohrringe'],
]; ];
} }
public static function selectColumns(string $alias = 'p'): string public static function legacyColumnsFromConfig(string $seed, array $config): array
{ {
$columns = array_keys(self::defaults()); $config = self::normalizeConfig($config);
return implode(', ', array_map(
static fn(string $column): string => sprintf('%s.%s', $alias, $column), return [
$columns '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 normalize(array $input): array public static function legacyConfig(array $input): array
{ {
$defaults = self::defaults(); return self::normalizeConfig([
$normalized = []; 'eyesVariant' => (string)($input['avatar_lorelei_eyes_variant'] ?? ''),
'eyebrowsVariant' => (string)($input['avatar_lorelei_eyebrows_variant'] ?? ''),
foreach ($defaults as $key => $defaultValue) { 'mouthVariant' => (string)($input['avatar_lorelei_mouth_variant'] ?? ''),
$value = (string)($input[$key] ?? $defaultValue); 'glassesVariant' => (string)($input['avatar_lorelei_glasses_variant'] ?? ''),
if ($key === 'avatar_preset') { 'hairVariant' => (string)($input['avatar_lorelei_hair_variant'] ?? ''),
$normalized[$key] = self::normalizeSeed($value); 'beardVariant' => (string)($input['avatar_lorelei_beard_variant'] ?? ''),
continue; 'earringsVariant' => (string)($input['avatar_lorelei_earrings_variant'] ?? ''),
} ]);
if (str_starts_with($key, 'avatar_lorelei_')) {
$normalized[$key] = self::normalizeVariant($value);
continue;
}
$normalized[$key] = $value;
}
return $normalized;
} }
public static function normalizeSeed(string $seed): string public static function normalizeSeed(string $seed): string
@@ -84,96 +95,94 @@ final class Lorelei
$seed = trim($seed, '-'); $seed = trim($seed, '-');
if ($seed === '') { if ($seed === '') {
$seed = self::defaults()['avatar_preset']; $seed = self::defaultSeed();
} }
return substr($seed, 0, 64); return substr($seed, 0, 100);
} }
public static function apiQuery(array $row): array public static function normalizeConfig(array $input): array
{ {
$avatar = self::normalize($row); $defaults = self::defaultConfig();
$query = ['seed' => $avatar['avatar_preset']]; $normalized = [];
foreach ($defaults as $key => $defaultValue) {
$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 = [ $requiredMap = [
'avatar_lorelei_eyes_variant' => 'eyesVariant', 'eyesVariant' => 'eyesVariant',
'avatar_lorelei_eyebrows_variant' => 'eyebrowsVariant', 'eyebrowsVariant' => 'eyebrowsVariant',
'avatar_lorelei_mouth_variant' => 'mouthVariant', 'mouthVariant' => 'mouthVariant',
]; ];
$optionalMap = [ $optionalMap = [
'avatar_lorelei_glasses_variant' => ['variant' => 'glassesVariant', 'probability' => 'glassesProbability'], 'glassesVariant' => ['variant' => 'glassesVariant', 'probability' => 'glassesProbability'],
'avatar_lorelei_beard_variant' => ['variant' => 'beardVariant', 'probability' => 'beardProbability'], 'beardVariant' => ['variant' => 'beardVariant', 'probability' => 'beardProbability'],
'avatar_lorelei_earrings_variant' => ['variant' => 'earringsVariant', 'probability' => 'earringsProbability'], 'earringsVariant' => ['variant' => 'earringsVariant', 'probability' => 'earringsProbability'],
]; ];
foreach ($requiredMap as $key => $param) { foreach ($requiredMap as $key => $param) {
if ($avatar[$key] !== '') { if ($config[$key] !== '') {
$query[$param] = $avatar[$key]; $query[$param] = $config[$key];
} }
} }
$query['hairProbability'] = '100'; $query['hairProbability'] = '100';
if ($avatar['avatar_lorelei_hair_variant'] !== '') { if ($config['hairVariant'] !== '') {
$query['hairVariant'] = $avatar['avatar_lorelei_hair_variant']; $query['hairVariant'] = $config['hairVariant'];
} }
foreach ($optionalMap as $key => $params) { foreach ($optionalMap as $key => $params) {
if ($avatar[$key] === '') { if ($config[$key] === '') {
$query[$params['probability']] = '0'; $query[$params['probability']] = '0';
continue; continue;
} }
$query[$params['variant']] = $avatar[$key]; $query[$params['variant']] = $config[$key];
$query[$params['probability']] = '100'; $query[$params['probability']] = '100';
} }
return $query; return $query;
} }
public static function buildProxyUrl(array $row): string public static function queryToConfig(array $query): array
{ {
return '/api/avatar.php?' . http_build_query(self::apiQuery($row)); $config = self::defaultConfig();
} $map = [
'eyesVariant' => 'eyesVariant',
public static function previewChoices(array $row, string $field): array 'eyebrowsVariant' => 'eyebrowsVariant',
{ 'mouthVariant' => 'mouthVariant',
$definitions = self::componentDefinitions(); 'glassesVariant' => 'glassesVariant',
$options = self::options(); 'hairVariant' => 'hairVariant',
$avatar = self::normalize($row); 'beardVariant' => 'beardVariant',
'earringsVariant' => 'earringsVariant',
if (!isset($definitions[$field], $options[$field])) {
return [];
}
$choices = [];
foreach ($options[$field] as $value => $label) {
$variantRow = $avatar;
$variantRow[$field] = (string) $value;
$choices[] = [
'value' => (string) $value,
'label' => (string) $label,
'image' => self::buildProxyUrl($variantRow),
'checked' => $avatar[$field] === (string) $value,
]; ];
foreach ($map as $field => $param) {
$config[$field] = self::normalizeVariant((string)($query[$param] ?? ''));
} }
return $choices; 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'] = '';
} }
public static function render(array $row, string $name = 'Mitglied', string $size = 'md'): string return self::normalizeConfig($config);
{
$avatar = self::normalize($row);
$src = self::buildProxyUrl($avatar);
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--' . preg_replace('/[^a-z0-9\-]/', '', strtolower($size)),
'pkt-avatar--generated',
]), ENT_QUOTES),
htmlspecialchars('Avatar von ' . $name, ENT_QUOTES),
htmlspecialchars($src, ENT_QUOTES)
);
} }
public static function fallbackSvg(string $seed, string $label = 'Avatar'): string public static function fallbackSvg(string $seed, string $label = 'Avatar'): string

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace App; namespace App;
use App\Avatar\Lorelei; use App\Avatar\AvatarManager;
final class Community final class Community
{ {
@@ -152,8 +152,9 @@ final class Community
$where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : ''; $where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : '';
$avatarSelect = $this->hasColumn('user_profiles', 'avatar_preset') $avatarColumns = $this->avatarProfileSelect('p');
? ', ' . Lorelei::selectColumns('p') $avatarSelect = $avatarColumns !== ''
? ', ' . $avatarColumns
: ''; : '';
$sql = "SELECT ft.id, ft.title, ft.body, ft.created_at, ft.updated_at, $sql = "SELECT ft.id, ft.title, ft.body, ft.created_at, ft.updated_at,
u.id as uid, u.created_at as user_created, u.id as uid, u.created_at as user_created,
@@ -208,8 +209,9 @@ final class Community
public function getThread(int $id): ?array public function getThread(int $id): ?array
{ {
$select = 'ft.*, p.display_name'; $select = 'ft.*, p.display_name';
if ($this->hasColumn('user_profiles', 'avatar_preset')) { $avatarColumns = $this->avatarProfileSelect('p');
$select .= ', ' . Lorelei::selectColumns('p'); if ($avatarColumns !== '') {
$select .= ', ' . $avatarColumns;
} }
$join = ''; $join = '';
if ($this->hasColumn('forum_threads', 'board_id') && $this->hasTable('forum_boards') && $this->hasTable('forum_categories')) { if ($this->hasColumn('forum_threads', 'board_id') && $this->hasTable('forum_boards') && $this->hasTable('forum_categories')) {
@@ -228,8 +230,9 @@ final class Community
public function listPosts(int $threadId): array public function listPosts(int $threadId): array
{ {
$select = 'fp.*, p.display_name'; $select = 'fp.*, p.display_name';
if ($this->hasColumn('user_profiles', 'avatar_preset')) { $avatarColumns = $this->avatarProfileSelect('p');
$select .= ', ' . Lorelei::selectColumns('p'); if ($avatarColumns !== '') {
$select .= ', ' . $avatarColumns;
} }
if ($this->hasColumn('forum_posts', 'highlighted_at')) { if ($this->hasColumn('forum_posts', 'highlighted_at')) {
$select .= ', hp.display_name AS highlighted_by_name'; $select .= ', hp.display_name AS highlighted_by_name';
@@ -538,4 +541,17 @@ final class Community
return $this->columnCache[$key] = false; 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);
}
} }

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App; namespace App;
use App\Avatar\AvatarManager;
use App\Avatar\Lorelei; use App\Avatar\Lorelei;
final class ProfileSettings final class ProfileSettings
@@ -31,6 +32,9 @@ final class ProfileSettings
} }
$avatarColumns = [ $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_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_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_eyebrows_variant' => "ALTER TABLE user_profiles ADD COLUMN avatar_lorelei_eyebrows_variant VARCHAR(24) NOT NULL DEFAULT '' AFTER avatar_lorelei_eyes_variant",
@@ -85,10 +89,16 @@ final class ProfileSettings
} }
$this->ensureSchema(); $this->ensureSchema();
$avatar = Lorelei::normalize($avatarConfig); $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( $stmt = $this->pdo->prepare(
'UPDATE user_profiles 'UPDATE user_profiles
SET avatar_preset = :preset, SET avatar_style = :avatarStyle,
avatar_seed = :avatarSeed,
avatar_config_json = :avatarConfigJson,
avatar_preset = :preset,
avatar_lorelei_eyes_variant = :eyesVariant, avatar_lorelei_eyes_variant = :eyesVariant,
avatar_lorelei_eyebrows_variant = :eyebrowsVariant, avatar_lorelei_eyebrows_variant = :eyebrowsVariant,
avatar_lorelei_mouth_variant = :mouthVariant, avatar_lorelei_mouth_variant = :mouthVariant,
@@ -100,14 +110,17 @@ final class ProfileSettings
WHERE user_id = :id' WHERE user_id = :id'
); );
$stmt->execute([ $stmt->execute([
'preset' => $avatar['avatar_preset'], 'avatarStyle' => $avatar['avatar_style'],
'eyesVariant' => $avatar['avatar_lorelei_eyes_variant'], 'avatarSeed' => $avatar['avatar_seed'],
'eyebrowsVariant' => $avatar['avatar_lorelei_eyebrows_variant'], 'avatarConfigJson' => $avatar['avatar_config_json'],
'mouthVariant' => $avatar['avatar_lorelei_mouth_variant'], 'preset' => $legacy['avatar_preset'],
'glassesVariant' => $avatar['avatar_lorelei_glasses_variant'], 'eyesVariant' => $legacy['avatar_lorelei_eyes_variant'],
'hairVariant' => $avatar['avatar_lorelei_hair_variant'], 'eyebrowsVariant' => $legacy['avatar_lorelei_eyebrows_variant'],
'beardVariant' => $avatar['avatar_lorelei_beard_variant'], 'mouthVariant' => $legacy['avatar_lorelei_mouth_variant'],
'earringsVariant' => $avatar['avatar_lorelei_earrings_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, 'id' => $userId,
]); ]);
} }