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

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

View File

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

View File

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

View File

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

View File

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