This commit is contained in:
@@ -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