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