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“
Aktueller Projektstand
- Papa-Kind-Treff nutzt im Mitgliederbereich DiceBear `lorelei` über einen internen API-Proxy mit lokalem Cache.
- Relevante Logik liegt in `src/App/Avatar/Lorelei.php`.
- Papa-Kind-Treff nutzt im Mitgliederbereich eine Mehr-Style-Avatar-Architektur auf Basis von DiceBear.
- 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`.
- 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.
- Beim Oeffnen des Modals werden nur die Bilder des aktiven Tabs geladen; weitere Tabs laden ihre Varianten erst beim Wechsel nach.
- Alte statische Avatar-Presets und frühere Layer-Assets sind entfernt; aktiv ist nur noch der Lorelei-Generator.
- Der Generator speichert `avatar_style`, `avatar_seed` und `avatar_config_json` generisch im Profil.
- Gespeicherte Benutzer-Avatare werden lokal unter `public/assets/avatars/users/ptk-<userId>.svg` abgelegt und bei Änderungen überschrieben.
- 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.
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/`
- MySQL/MariaDB-Schema in `schema.sql`
- 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`
- Der Avatar-Endpunkt ist als direkte Datei unter `public/api/avatar.php` angebunden und laeuft bewusst ohne App-Bootstrap.
- Avatar-Manager liegt zentral unter `src/App/Avatar/AvatarManager.php`
- 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
- 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.
- Moderation ist getrennt vom normalen Community-Frontend in einem eigenen Adminbereich.
- 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.
- Der Avatar-Editor bietet aktuell visuelle Komponenten-Vorschauen statt reiner Text-Dropdowns.
- Frühere statische Preset-Avatar-Assets wurden entfernt; der aktuelle Stand nutzt nur noch den Generator mit Proxy/Cache.
- Profilbilder werden aktuell über die Mehr-Style-Architektur serverseitig erzeugt; aktiv ist derzeit `lorelei`.
- Stil, Seed und Varianten werden generisch im Profil gespeichert; das fertige SVG wird lokal pro Benutzer unter `ptk-<userId>.svg` abgelegt.
- Der Avatar-Editor bietet visuelle Komponenten-Vorschauen statt reiner Text-Dropdowns und laedt nur den aktiven Style/Tab nach.
## Navigation / Konto
- Eingeloggte Nutzer sehen rechts ein Profil-Menü statt einzelner `Dashboard`-/`Logout`-Buttons.
- Das Profil-Menü enthält aktuell `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen`, `Abmelden`.
- 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.
- Der Button `Random Profilbild` setzt Seed und Varianten 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.
- 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 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.
## Community und Rechte
@@ -59,7 +61,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
## Doku-Regel
Bei jeder künftigen Änderung müssen mindestens diese Dateien mitgeprüft werden:
- `README.md`
- `.projectstructure.txt`
- `.projektstructure.txt`
- `PROJECT_CONTEXT.md`
Wenn Cookies, LocalStorage, SessionStorage, Tracking, Geolocation oder Drittanbieter neu hinzukommen oder geändert werden, müssen zusätzlich Consent und rechtliche Hinweise angepasst werden.

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
- standortbasierte Sortierung für die neuesten Treffen
- 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
- Profilbild-Generator mit visueller Komponenten-Auswahl für Augen, Augenbrauen, Mund, Brille, Haare, Bart und Ohrringe sowie einem `Random Profilbild`-Button
- keine statischen Avatar-Preset-Dateien mehr im Repository; Vorschau und Auswahl laufen vollständig über den Lorelei-Generator
- modernes Avatar-System mit zentraler Mehr-Style-Architektur auf Basis von DiceBear
- aktiver Avatar-Style aktuell: `lorelei`; weitere Styles können über `config/avatar.php` freigeschaltet werden
- 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
- Navigation aktuell mit `Home`, `Suche`, `Community`
- 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`
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`.
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
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`)
- Leaflet von `unpkg.com` (`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:
- Nicht notwendige Analyse- und Drittanbieterfunktionen dürfen erst nach Einwilligung aktiv werden.
@@ -61,7 +64,7 @@ Wichtig:
## Dokumentationspflicht bei Änderungen
Bei jeder Änderung im Projekt immer mitprüfen und bei Bedarf aktualisieren:
- `README.md`
- `.projectstructure.txt`
- `.projektstructure.txt`
- `PROJECT_CONTEXT.md`
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
Für spätere Chats als Einstieg besonders wichtig:
- `README.md`
- `.projectstructure.txt`
- `.projektstructure.txt`
- `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);
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';
$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;
$actionEvent = $editing ? 'event_update' : 'event_add';
$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="profile-avatar-panel">
<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 class="profile-avatar-panel__content">
<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>
</div>
</div>
@@ -298,69 +302,88 @@ $sectionLinks = [
</div>
<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="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__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>
<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;">
<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" type="submit">Profilbild speichern</button>
</div>
<div class="avatar-component-nav" role="tablist" aria-label="Avatar-Komponenten">
<?php foreach (\App\Avatar\Lorelei::componentDefinitions() as $field => $meta): ?>
<?php
$componentOptions = $avatarOptions[$field] ?? [];
$componentCount = count(array_filter(array_keys($componentOptions), static fn ($value): bool => (string) $value !== ''));
?>
<button
type="button"
class="avatar-component-nav__button <?= $field === 'avatar_lorelei_eyes_variant' ? 'is-active' : '' ?>"
data-avatar-component-tab="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"
aria-selected="<?= $field === 'avatar_lorelei_eyes_variant' ? 'true' : 'false' ?>"
>
<?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?>
<span class="muted small">(<?= $componentCount ?>)</span>
</button>
<?php endforeach; ?>
</div>
<div class="avatar-component-panels">
<?php foreach (\App\Avatar\Lorelei::componentDefinitions() as $field => $meta): ?>
<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) ?>">
<div class="avatar-component-panel__head">
<strong><?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?></strong>
<span class="muted small">Gesamtes Profilbild zur sicheren Vorschau</span>
</div>
<div class="avatar-component-grid">
<?php foreach (\App\Avatar\Lorelei::previewChoices($profile, $field) as $choice): ?>
<label class="avatar-component-card">
<input
type="radio"
name="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"
value="<?= htmlspecialchars((string)$choice['value'], ENT_QUOTES) ?>"
data-avatar-field="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"
<?= $choice['checked'] ? 'checked' : '' ?>
>
<span class="avatar-component-card__thumb">
<img
src="<?= htmlspecialchars($avatarPlaceholder, ENT_QUOTES) ?>"
alt="<?= htmlspecialchars((string)$choice['label'], ENT_QUOTES) ?>"
data-avatar-option-thumb
data-avatar-option-src="<?= htmlspecialchars((string)$choice['image'], ENT_QUOTES) ?>"
data-avatar-option-field="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"
data-avatar-option-value="<?= htmlspecialchars((string)$choice['value'], ENT_QUOTES) ?>"
>
</span>
<span class="avatar-component-card__label"><?= htmlspecialchars((string)$choice['label'], ENT_QUOTES) ?></span>
</label>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
</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">
<?php $firstField = array_key_first($styleComponents); ?>
<?php foreach ($styleComponents as $field => $component): ?>
<button
type="button"
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) ?>"
aria-selected="<?= $field === $firstField ? 'true' : 'false' ?>"
>
<?= htmlspecialchars((string)($component['label'] ?? $field), ENT_QUOTES) ?>
<span class="muted small">(<?= (int)($component['count'] ?? 0) ?>)</span>
</button>
<?php endforeach; ?>
</div>
<div class="avatar-component-panels">
<?php foreach ($styleComponents as $field => $component): ?>
<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">
<strong><?= htmlspecialchars((string)($component['label'] ?? $field), ENT_QUOTES) ?></strong>
<span class="muted small">Gesamtes Profilbild zur sicheren Vorschau</span>
</div>
<div class="avatar-component-grid">
<?php foreach (($component['options'] ?? []) as $choice): ?>
<label class="avatar-component-card">
<input
type="radio"
name="avatar_config[<?= htmlspecialchars($styleKey, ENT_QUOTES) ?>][<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>]"
value="<?= htmlspecialchars((string)($choice['value'] ?? ''), ENT_QUOTES) ?>"
data-avatar-style="<?= htmlspecialchars($styleKey, ENT_QUOTES) ?>"
data-avatar-field="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"
<?= !empty($choice['checked']) ? 'checked' : '' ?>
>
<span class="avatar-component-card__thumb">
<img
src="<?= htmlspecialchars($avatarPlaceholder, ENT_QUOTES) ?>"
alt="<?= htmlspecialchars((string)($choice['label'] ?? ''), ENT_QUOTES) ?>"
data-avatar-option-thumb
data-avatar-style="<?= htmlspecialchars($styleKey, ENT_QUOTES) ?>"
data-avatar-option-field="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"
data-avatar-option-value="<?= htmlspecialchars((string)($choice['value'] ?? ''), ENT_QUOTES) ?>"
>
</span>
<span class="avatar-component-card__label"><?= htmlspecialchars((string)($choice['label'] ?? ''), ENT_QUOTES) ?></span>
</label>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</form>

View File

@@ -115,7 +115,7 @@ $threads = $community
?>
<article class="forum-row">
<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>
<h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3>
<div class="forum-row__meta">
@@ -206,7 +206,7 @@ $threads = $community
?>
<article class="forum-row">
<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>
<h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3>
<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">
<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>
<span><?= htmlspecialchars(trim((string)($threadLevel['icon'] ?? '') . ' ' . (string)($threadLevel['label'] ?? '')), ENT_QUOTES) ?></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' : '' ?>">
<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>
<?php if ($isHighlighted): ?>
<span class="forum-highlight-badge">Hilfreich hervorgehoben</span>

View File

@@ -11,7 +11,11 @@ if ($isLoggedIn) {
if ($pdo) {
$profileSettings = new \App\ProfileSettings($pdo);
$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']]);
$profileRow = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
$displayName = trim((string)($profileRow['display_name'] ?? '')) ?: trim((string)($profileRow['first_name'] ?? '')) ?: 'Profil';
@@ -48,7 +52,7 @@ if ($isLoggedIn) {
<?php if ($isLoggedIn): ?>
<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\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>
</button>
<div class="profile-menu__dropdown" role="menu">

View File

@@ -1,61 +1,11 @@
<?php
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'];
$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');
}
$svg = AvatarManager::fetchSvgFromQuery($_GET);
header('Content-Type: image/svg+xml; charset=utf-8');
header('Cache-Control: public, max-age=604800, immutable');

View File

@@ -726,51 +726,73 @@ document.addEventListener('DOMContentLoaded', () => {
});
document.addEventListener('pkt:open-consent', openConsentModal);
const readAvatarValue = (form, field) => {
const checked = form.querySelector(`[data-avatar-field="${field}"]:checked`);
const avatarStyleConfigs = {
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;
const input = form.querySelector(`[data-avatar-field="${field}"]`);
const input = panel.querySelector(`[data-avatar-field="${field}"]`);
return input ? input.value : '';
};
const avatarComponentFieldMap = {
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',
const getAvatarStyleConfig = (style) => {
if (avatarStyleConfigs[style]) return avatarStyleConfigs[style];
return avatarStyleConfigs.lorelei;
};
const avatarComponentFields = Object.keys(avatarComponentFieldMap);
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 seed = overrides.avatar_preset || readAvatarValue(form, 'avatar_preset');
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({ seed });
const hairValue = Object.prototype.hasOwnProperty.call(overrides, 'avatar_lorelei_hair_variant')
? 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 params = new URLSearchParams({ style, seed });
styleConfig.fields.forEach((field) => {
const value = Object.prototype.hasOwnProperty.call(overrides, field)
? overrides[field]
: readAvatarValue(form, field);
const param = avatarComponentFieldMap[field];
const probabilityParam = avatarOptionalProbabilityMap[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');
@@ -788,7 +810,8 @@ document.addEventListener('DOMContentLoaded', () => {
const image = preview.querySelector('.pkt-avatar__img');
if (!image) return;
const params = buildAvatarParams(form);
const style = getCurrentAvatarStyle(form);
const params = buildAvatarParams(form, style);
if (!params) return;
const url = `${avatarEndpoint}?${params.toString()}`;
@@ -796,20 +819,24 @@ document.addEventListener('DOMContentLoaded', () => {
image.src = url;
};
const getActiveAvatarField = (form) => {
const activeTab = form.querySelector('[data-avatar-component-tab].is-active');
return activeTab?.getAttribute('data-avatar-component-tab') || 'avatar_lorelei_eyes_variant';
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, activeField = null) => {
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]';
form.querySelectorAll(selector).forEach((image) => {
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, { [field]: value });
const params = buildAvatarParams(form, style, { [field]: value });
if (!params) return;
const src = `${avatarEndpoint}?${params.toString()}`;
@@ -819,52 +846,83 @@ document.addEventListener('DOMContentLoaded', () => {
});
};
const ensureAvatarBuilderLoaded = (form) => {
if (form.dataset.avatarInitialized === 'true') return;
form.dataset.avatarInitialized = 'true';
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, getActiveAvatarField(form));
updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
};
document.querySelectorAll('[data-avatar-builder]').forEach((form) => {
form.querySelectorAll('[data-avatar-field]').forEach((field) => {
field.addEventListener('change', () => {
ensureAvatarBuilderLoaded(form);
const style = field.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
ensureAvatarBuilderLoaded(form, style);
updateAvatarPreview(form);
updateAvatarOptionPreviews(form, getActiveAvatarField(form));
updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
});
field.addEventListener('input', () => {
ensureAvatarBuilderLoaded(form);
const style = field.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
ensureAvatarBuilderLoaded(form, style);
updateAvatarPreview(form);
updateAvatarOptionPreviews(form, getActiveAvatarField(form));
updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style));
});
});
const setActiveComponentPanel = (field) => {
form.querySelectorAll('[data-avatar-component-tab]').forEach((button) => {
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');
});
form.querySelectorAll('[data-avatar-component-panel]').forEach((panel) => {
panel.classList.toggle('is-active', panel.getAttribute('data-avatar-component-panel') === field);
panel.querySelectorAll('[data-avatar-component-panel]').forEach((componentPanel) => {
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) => {
button.addEventListener('click', () => {
const style = button.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form);
const field = button.getAttribute('data-avatar-component-tab');
if (field) {
ensureAvatarBuilderLoaded(form);
setActiveComponentPanel(field);
ensureAvatarBuilderLoaded(form, style);
setActiveComponentPanel(style, field);
}
});
});
avatarComponentFields.forEach((field) => {
form.querySelectorAll(`[data-avatar-field="${field}"]`).forEach((input) => {
input.addEventListener('change', () => setActiveComponentPanel(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);
});
});
});
});
@@ -891,29 +949,36 @@ document.addEventListener('DOMContentLoaded', () => {
};
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;
seedField.value = buildSeed();
avatarComponentFields.forEach((field) => {
const options = Array.from(form.querySelectorAll(`[data-avatar-field="${field}"]`));
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);
ensureAvatarBuilderLoaded(form, style);
updateAvatarPreview(form);
updateAvatarOptionPreviews(form, getActiveAvatarField(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;
window.setTimeout(() => ensureAvatarBuilderLoaded(form), 20);
const style = getCurrentAvatarStyle(form);
window.setTimeout(() => ensureAvatarBuilderLoaded(form, style), 20);
});
});

View File

@@ -1,60 +1,4 @@
<?php
declare(strict_types=1);
use App\Avatar\Lorelei;
$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;
require dirname(__DIR__, 2) . '/api/avatar.php';

View File

@@ -151,9 +151,10 @@ $clientCookie = $config->cookiePrefix() . 'client';
<section>
<h2>10. Profilbilder mit DiceBear</h2>
<p>
Profilbilder werden serverseitig über den Dienst DiceBear im Stil „Lorelei“ erzeugt. Dabei wird für die
Generierung nur der im Profil gespeicherte Avatar-Seed verwendet. Der Abruf erfolgt über unsere Anwendung
und wird lokal zwischengespeichert, sodass dein Browser die Avatar-Grafiken nicht direkt bei DiceBear lädt.
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

View File

@@ -30,6 +30,9 @@ 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 '',

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App;
use App\Avatar\AvatarManager;
use App\Avatar\Lorelei;
final class AccountPages
@@ -340,6 +341,9 @@ final class AccountPages
'email' => '',
'contact_phone' => '',
'location_tracking_preference' => 'prompt',
'avatar_style' => AvatarManager::defaultStyle(),
'avatar_seed' => '',
'avatar_config_json' => '',
'avatar_preset' => 'papa-kind-treff',
'avatar_lorelei_eyes_variant' => '',
'avatar_lorelei_eyebrows_variant' => '',
@@ -352,7 +356,11 @@ final class AccountPages
if ($profileSettings) {
$profileSettings->ensureSchema();
}
$stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone, p.location_tracking_preference, 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]);
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
if ($row) {
@@ -361,7 +369,7 @@ final class AccountPages
$profile['contact_phone'] = $crypto->decrypt((string)$profile['contact_phone']) ?: '';
}
}
$profile = array_merge($profile, Lorelei::normalize($profile));
$profile = AvatarManager::normalizeProfile($profile, $userId);
$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');
@@ -418,7 +426,7 @@ final class AccountPages
if (!in_array($section, $allowedSections, true)) {
$section = 'profile';
}
$avatarOptions = Lorelei::options();
$avatarBuilder = AvatarManager::builderStyles($profile);
return compact(
'flash',
@@ -435,7 +443,7 @@ final class AccountPages
'communityApplication',
'communityCanApply',
'communityRestrictions',
'avatarOptions',
'avatarBuilder',
'section',
'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
{
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 [
'avatar_preset' => 'papa-kind-treff',
'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' => '',
'eyesVariant' => '',
'eyebrowsVariant' => '',
'mouthVariant' => '',
'glassesVariant' => '',
'hairVariant' => '',
'beardVariant' => '',
'earringsVariant' => '',
];
}
public static function options(): array
{
return [
'avatar_lorelei_eyes_variant' => self::variantOptions(24),
'avatar_lorelei_eyebrows_variant' => self::variantOptions(13),
'avatar_lorelei_mouth_variant' => self::mouthVariantOptions(),
'avatar_lorelei_glasses_variant' => ['' => 'Keine'] + self::variantOptions(5),
'avatar_lorelei_hair_variant' => ['' => 'Keine'] + self::variantOptions(48),
'avatar_lorelei_beard_variant' => ['' => 'Keine'] + self::variantOptions(2),
'avatar_lorelei_earrings_variant' => ['' => 'Keine'] + self::variantOptions(3),
'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 [
'avatar_lorelei_eyes_variant' => ['label' => 'Augen', 'auto' => false],
'avatar_lorelei_eyebrows_variant' => ['label' => 'Augenbrauen', 'auto' => false],
'avatar_lorelei_mouth_variant' => ['label' => 'Mund', 'auto' => false],
'avatar_lorelei_glasses_variant' => ['label' => 'Brille', 'auto' => true],
'avatar_lorelei_hair_variant' => ['label' => 'Haare', 'auto' => true],
'avatar_lorelei_beard_variant' => ['label' => 'Bart', 'auto' => true],
'avatar_lorelei_earrings_variant' => ['label' => 'Ohrringe', 'auto' => true],
'eyesVariant' => ['label' => 'Augen'],
'eyebrowsVariant' => ['label' => 'Augenbrauen'],
'mouthVariant' => ['label' => 'Mund'],
'glassesVariant' => ['label' => 'Brille'],
'hairVariant' => ['label' => 'Haare'],
'beardVariant' => ['label' => 'Bart'],
'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());
return implode(', ', array_map(
static fn(string $column): string => sprintf('%s.%s', $alias, $column),
$columns
));
$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 normalize(array $input): array
public static function legacyConfig(array $input): array
{
$defaults = self::defaults();
$normalized = [];
foreach ($defaults as $key => $defaultValue) {
$value = (string)($input[$key] ?? $defaultValue);
if ($key === 'avatar_preset') {
$normalized[$key] = self::normalizeSeed($value);
continue;
}
if (str_starts_with($key, 'avatar_lorelei_')) {
$normalized[$key] = self::normalizeVariant($value);
continue;
}
$normalized[$key] = $value;
}
return $normalized;
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
@@ -84,96 +95,94 @@ final class Lorelei
$seed = trim($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);
$query = ['seed' => $avatar['avatar_preset']];
$defaults = self::defaultConfig();
$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 = [
'avatar_lorelei_eyes_variant' => 'eyesVariant',
'avatar_lorelei_eyebrows_variant' => 'eyebrowsVariant',
'avatar_lorelei_mouth_variant' => 'mouthVariant',
'eyesVariant' => 'eyesVariant',
'eyebrowsVariant' => 'eyebrowsVariant',
'mouthVariant' => 'mouthVariant',
];
$optionalMap = [
'avatar_lorelei_glasses_variant' => ['variant' => 'glassesVariant', 'probability' => 'glassesProbability'],
'avatar_lorelei_beard_variant' => ['variant' => 'beardVariant', 'probability' => 'beardProbability'],
'avatar_lorelei_earrings_variant' => ['variant' => 'earringsVariant', 'probability' => 'earringsProbability'],
'glassesVariant' => ['variant' => 'glassesVariant', 'probability' => 'glassesProbability'],
'beardVariant' => ['variant' => 'beardVariant', 'probability' => 'beardProbability'],
'earringsVariant' => ['variant' => 'earringsVariant', 'probability' => 'earringsProbability'],
];
foreach ($requiredMap as $key => $param) {
if ($avatar[$key] !== '') {
$query[$param] = $avatar[$key];
if ($config[$key] !== '') {
$query[$param] = $config[$key];
}
}
$query['hairProbability'] = '100';
if ($avatar['avatar_lorelei_hair_variant'] !== '') {
$query['hairVariant'] = $avatar['avatar_lorelei_hair_variant'];
if ($config['hairVariant'] !== '') {
$query['hairVariant'] = $config['hairVariant'];
}
foreach ($optionalMap as $key => $params) {
if ($avatar[$key] === '') {
if ($config[$key] === '') {
$query[$params['probability']] = '0';
continue;
}
$query[$params['variant']] = $avatar[$key];
$query[$params['variant']] = $config[$key];
$query[$params['probability']] = '100';
}
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',
'eyebrowsVariant' => 'eyebrowsVariant',
'mouthVariant' => 'mouthVariant',
'glassesVariant' => 'glassesVariant',
'hairVariant' => 'hairVariant',
'beardVariant' => 'beardVariant',
'earringsVariant' => 'earringsVariant',
];
public static function previewChoices(array $row, string $field): array
{
$definitions = self::componentDefinitions();
$options = self::options();
$avatar = self::normalize($row);
if (!isset($definitions[$field], $options[$field])) {
return [];
foreach ($map as $field => $param) {
$config[$field] = self::normalizeVariant((string)($query[$param] ?? ''));
}
$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,
];
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'] = '';
}
return $choices;
}
public static function render(array $row, string $name = 'Mitglied', string $size = 'md'): string
{
$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)
);
return self::normalizeConfig($config);
}
public static function fallbackSvg(string $seed, string $label = 'Avatar'): string

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace App;
use App\Avatar\Lorelei;
use App\Avatar\AvatarManager;
final class Community
{
@@ -152,8 +152,9 @@ final class Community
$where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : '';
$avatarSelect = $this->hasColumn('user_profiles', 'avatar_preset')
? ', ' . Lorelei::selectColumns('p')
$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,
@@ -208,8 +209,9 @@ final class Community
public function getThread(int $id): ?array
{
$select = 'ft.*, p.display_name';
if ($this->hasColumn('user_profiles', 'avatar_preset')) {
$select .= ', ' . Lorelei::selectColumns('p');
$avatarColumns = $this->avatarProfileSelect('p');
if ($avatarColumns !== '') {
$select .= ', ' . $avatarColumns;
}
$join = '';
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
{
$select = 'fp.*, p.display_name';
if ($this->hasColumn('user_profiles', 'avatar_preset')) {
$select .= ', ' . Lorelei::selectColumns('p');
$avatarColumns = $this->avatarProfileSelect('p');
if ($avatarColumns !== '') {
$select .= ', ' . $avatarColumns;
}
if ($this->hasColumn('forum_posts', 'highlighted_at')) {
$select .= ', hp.display_name AS highlighted_by_name';
@@ -538,4 +541,17 @@ 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);
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App;
use App\Avatar\AvatarManager;
use App\Avatar\Lorelei;
final class ProfileSettings
@@ -31,6 +32,9 @@ final class ProfileSettings
}
$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",
@@ -85,10 +89,16 @@ final class ProfileSettings
}
$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(
'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_eyebrows_variant = :eyebrowsVariant,
avatar_lorelei_mouth_variant = :mouthVariant,
@@ -100,14 +110,17 @@ final class ProfileSettings
WHERE user_id = :id'
);
$stmt->execute([
'preset' => $avatar['avatar_preset'],
'eyesVariant' => $avatar['avatar_lorelei_eyes_variant'],
'eyebrowsVariant' => $avatar['avatar_lorelei_eyebrows_variant'],
'mouthVariant' => $avatar['avatar_lorelei_mouth_variant'],
'glassesVariant' => $avatar['avatar_lorelei_glasses_variant'],
'hairVariant' => $avatar['avatar_lorelei_hair_variant'],
'beardVariant' => $avatar['avatar_lorelei_beard_variant'],
'earringsVariant' => $avatar['avatar_lorelei_earrings_variant'],
'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,
]);
}