This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
19
README.md
19
README.md
@@ -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` fü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
28
config/avatar.php
Normal 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' => [],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '',
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
369
src/App/Avatar/AvatarManager.php
Normal file
369
src/App/Avatar/AvatarManager.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user