synch
All checks were successful
Deploy / deploy (push) Successful in 1m43s

This commit is contained in:
2026-07-27 00:24:12 +02:00
parent 56d29f09e5
commit e274e07f45
142 changed files with 235 additions and 295 deletions

View File

@@ -338,7 +338,7 @@ final class AccountPages
'email' => '',
'contact_phone' => '',
'location_tracking_preference' => 'prompt',
'avatar_preset' => 'modern_001',
'avatar_preset' => 'papa-kind-treff',
'avatar_skin_tone' => 'warm',
'avatar_hair_style' => 'short',
'avatar_hair_color' => 'brown',
@@ -420,9 +420,7 @@ final class AccountPages
if (!in_array($section, $allowedSections, true)) {
$section = 'profile';
}
$avatarOptions = Avatar::options();
$avatarPresetOptions = Avatar::presetOptions();
$avatarPresetGroups = Avatar::presetGroups();
$avatarPresetOptions = Avatar::seedChoices((string)$profile['avatar_preset']);
return compact(
'flash',
@@ -439,9 +437,7 @@ final class AccountPages
'communityApplication',
'communityCanApply',
'communityRestrictions',
'avatarOptions',
'avatarPresetOptions',
'avatarPresetGroups',
'section',
'allowedSections'
);

View File

@@ -8,14 +8,14 @@ final class Avatar
public static function defaults(): array
{
return [
'avatar_preset' => 'modern_001',
'avatar_preset' => 'papa-kind-treff',
'avatar_skin_tone' => 'warm',
'avatar_hair_style' => 'parted',
'avatar_hair_style' => 'short',
'avatar_hair_color' => 'brown',
'avatar_eye_color' => 'brown',
'avatar_beard_style' => 'short',
'avatar_beard_style' => 'none',
'avatar_glasses_style' => 'none',
'avatar_head_shape' => 'oval',
'avatar_head_shape' => 'round',
'avatar_hat_style' => 'none',
'avatar_hat_color' => 'navy',
'avatar_outfit_style' => 'tee',
@@ -25,122 +25,7 @@ final class Avatar
public static function options(): array
{
return [
'avatar_skin_tone' => [
'fair' => ['label' => 'Hell', 'color' => '#f6d7c1'],
'warm' => ['label' => 'Warm', 'color' => '#eec0a0'],
'golden' => ['label' => 'Gold', 'color' => '#d7a179'],
'deep' => ['label' => 'Tief', 'color' => '#9c633e'],
'rich' => ['label' => 'Kräftig', 'color' => '#6f432b'],
],
'avatar_hair_style' => [
'parted' => 'Seitenscheitel',
'short' => 'Kurz',
'fade' => 'Fade',
'curly' => 'Lockig',
'waves' => 'Wellig',
'bald' => 'Kahl',
],
'avatar_hair_color' => [
'black' => ['label' => 'Schwarz', 'color' => '#191919'],
'brown' => ['label' => 'Braun', 'color' => '#65442f'],
'blonde' => ['label' => 'Blond', 'color' => '#d3a053'],
'red' => ['label' => 'Rot', 'color' => '#9f5a34'],
'gray' => ['label' => 'Grau', 'color' => '#8f8c87'],
],
'avatar_eye_color' => [
'brown' => ['label' => 'Braun', 'color' => '#5e4330'],
'hazel' => ['label' => 'Hasel', 'color' => '#7c6848'],
'green' => ['label' => 'Grün', 'color' => '#4f6c52'],
'blue' => ['label' => 'Blau', 'color' => '#507196'],
],
'avatar_beard_style' => [
'none' => 'Ohne',
'mustache' => 'Schnurrbart',
'short' => 'Kurz',
'full' => 'Vollbart',
],
'avatar_glasses_style' => [
'none' => 'Ohne',
'round' => 'Rund',
'square' => 'Eckig',
],
'avatar_head_shape' => [
'round' => 'Rund',
'oval' => 'Oval',
'square' => 'Eckig',
],
'avatar_hat_style' => [
'none' => 'Ohne',
'beanie' => 'Beanie',
'cap' => 'Cap',
],
'avatar_hat_color' => [
'navy' => ['label' => 'Navy', 'color' => '#42526a'],
'charcoal' => ['label' => 'Anthrazit', 'color' => '#43464d'],
'forest' => ['label' => 'Waldgrün', 'color' => '#51634f'],
'brick' => ['label' => 'Ziegel', 'color' => '#9a5a47'],
'sand' => ['label' => 'Sand', 'color' => '#baa078'],
],
'avatar_outfit_style' => [
'tee' => 'T-Shirt',
'hoodie' => 'Hoodie',
'polo' => 'Polo',
'crew' => 'Pullover',
],
'avatar_outfit_color' => [
'slate' => ['label' => 'Schiefer', 'color' => '#5f7387'],
'graphite' => ['label' => 'Graphit', 'color' => '#4c515a'],
'olive' => ['label' => 'Oliv', 'color' => '#667155'],
'rust' => ['label' => 'Rost', 'color' => '#9f6547'],
'ocean' => ['label' => 'Ozean', 'color' => '#4e7897'],
],
];
}
public static function presetGroups(): array
{
return [
'fair' => ['label' => 'Helle Hauttöne', 'range' => [1, 20]],
'warm' => ['label' => 'Warme Hauttöne', 'range' => [21, 40]],
'golden' => ['label' => 'Goldene Hauttöne', 'range' => [41, 60]],
'deep' => ['label' => 'Tiefere Hauttöne', 'range' => [61, 80]],
'rich' => ['label' => 'Kräftige Hauttöne', 'range' => [81, 100]],
];
}
public static function presetOptions(): array
{
$presets = [];
foreach (self::presetGroups() as $groupKey => $group) {
[$start, $end] = $group['range'];
for ($index = $start; $index <= $end; $index++) {
$value = sprintf('modern_%03d', $index);
$presets[$groupKey][$value] = [
'label' => 'Variante ' . $index,
'group' => $groupKey,
'image' => self::presetPublicPath($value),
];
}
}
return $presets;
}
public static function presetGroupForPreset(string $preset): string
{
$preset = preg_replace('/[^a-z0-9_]/', '', strtolower($preset)) ?: '';
if (preg_match('/modern_(\d{3})/', $preset, $matches)) {
$number = (int)$matches[1];
foreach (self::presetGroups() as $groupKey => $group) {
[$start, $end] = $group['range'];
if ($number >= $start && $number <= $end) {
return $groupKey;
}
}
}
return 'fair';
return [];
}
public static function selectColumns(string $alias = 'p'): string
@@ -155,111 +40,117 @@ final class Avatar
public static function normalize(array $input): array
{
$defaults = self::defaults();
$options = self::options();
$normalized = [];
foreach ($defaults as $key => $defaultValue) {
$value = (string)($input[$key] ?? $defaultValue);
if (isset($options[$key]) && !array_key_exists($value, $options[$key])) {
$value = $defaultValue;
}
$normalized[$key] = $value;
$normalized[$key] = $key === 'avatar_preset'
? self::normalizeSeed($value)
: $value;
}
return $normalized;
}
public static function normalizeSeed(string $seed): string
{
$seed = strtolower(trim($seed));
$seed = preg_replace('/[^a-z0-9]+/', '-', $seed) ?: '';
$seed = trim($seed, '-');
if ($seed === '') {
$seed = self::defaults()['avatar_preset'];
}
return substr($seed, 0, 64);
}
public static function seedChoices(string $selectedSeed, int $count = 24): array
{
$selectedSeed = self::normalizeSeed($selectedSeed);
$prefixes = [
'abend', 'ahorn', 'anker', 'atlas', 'berg', 'blick', 'brise', 'echo',
'eiche', 'fjord', 'fluss', 'fokus', 'hafen', 'herz', 'horizont', 'insel',
'kiesel', 'kompass', 'kraft', 'linie', 'licht', 'lotse', 'mond', 'morgen',
'nord', 'pfad', 'quelle', 'rauch', 'runde', 'sand', 'sommer', 'stein',
'sturm', 'tal', 'ufer', 'wald', 'welle', 'wind', 'winkel', 'zeit',
];
$suffixes = [
'alpha', 'atlas', 'balu', 'bravo', 'caspar', 'dario', 'emil', 'felix',
'fiete', 'finn', 'gerrit', 'hanno', 'ilan', 'joris', 'kian', 'leon',
'linus', 'maik', 'malo', 'marlon', 'mats', 'mika', 'milan', 'noel',
'ole', 'oskar', 'pepe', 'quinn', 'remo', 'sam', 'taro', 'timo',
'veit', 'vito', 'yaro', 'yuri', 'zeno', 'zuri',
];
$choices = [[
'seed' => $selectedSeed,
'label' => 'Aktuelle Auswahl',
'image' => self::dicebearUrl($selectedSeed),
]];
$seen = [$selectedSeed => true];
$base = abs((int)crc32($selectedSeed));
$index = 0;
while (count($choices) < $count) {
$prefix = $prefixes[($base + ($index * 7)) % count($prefixes)];
$suffix = $suffixes[($base + ($index * 11)) % count($suffixes)];
$candidate = self::normalizeSeed($prefix . '-' . $suffix . '-' . (($base + $index) % 997));
$index++;
if (isset($seen[$candidate])) {
continue;
}
$seen[$candidate] = true;
$choices[] = [
'seed' => $candidate,
'label' => 'Variante ' . count($choices),
'image' => self::dicebearUrl($candidate),
];
}
return $choices;
}
public static function dicebearUrl(string $seed): string
{
return '/api/avatar?seed=' . rawurlencode(self::normalizeSeed($seed));
}
public static function render(array $row, string $name = 'Mitglied', string $size = 'md'): string
{
$avatar = self::normalize($row);
$presetPath = self::presetPublicPath($avatar['avatar_preset']);
if ($presetPath !== null) {
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--preset',
]), ENT_QUOTES),
htmlspecialchars('Avatar von ' . $name, ENT_QUOTES),
htmlspecialchars($presetPath, ENT_QUOTES)
);
}
$options = self::options();
$classes = [
'pkt-avatar',
'pkt-avatar--' . preg_replace('/[^a-z0-9\-]/', '', strtolower($size)),
'pkt-avatar--layered',
'pkt-avatar--head-' . $avatar['avatar_head_shape'],
'pkt-avatar--hair-' . $avatar['avatar_hair_style'],
'pkt-avatar--beard-' . $avatar['avatar_beard_style'],
'pkt-avatar--glasses-' . $avatar['avatar_glasses_style'],
'pkt-avatar--hat-' . $avatar['avatar_hat_style'],
'pkt-avatar--outfit-' . $avatar['avatar_outfit_style'],
];
$style = sprintf(
'--avatar-skin:%s;--avatar-hair:%s;--avatar-eye:%s;--avatar-hat:%s;--avatar-outfit:%s;',
$options['avatar_skin_tone'][$avatar['avatar_skin_tone']]['color'],
$options['avatar_hair_color'][$avatar['avatar_hair_color']]['color'],
$options['avatar_eye_color'][$avatar['avatar_eye_color']]['color'],
$options['avatar_hat_color'][$avatar['avatar_hat_color']]['color'],
$options['avatar_outfit_color'][$avatar['avatar_outfit_color']]['color']
);
$layers = [
self::renderLayer('outfit', $avatar['avatar_outfit_style']),
self::renderLayer('head', $avatar['avatar_head_shape']),
self::renderLayer('hair', $avatar['avatar_hair_style']),
self::renderLayer('hat', $avatar['avatar_hat_style']),
self::renderLayer('features', 'default'),
self::renderLayer('beard', $avatar['avatar_beard_style']),
self::renderLayer('glasses', $avatar['avatar_glasses_style']),
];
$src = self::dicebearUrl($avatar['avatar_preset']);
return sprintf(
'<span class="%s" style="%s" role="img" aria-label="%s">%s</span>',
htmlspecialchars(implode(' ', $classes), ENT_QUOTES),
htmlspecialchars($style, ENT_QUOTES),
'<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--preset',
]), ENT_QUOTES),
htmlspecialchars('Avatar von ' . $name, ENT_QUOTES),
implode('', array_filter($layers))
htmlspecialchars($src, ENT_QUOTES)
);
}
private static function renderLayer(string $category, string $variant): string
public static function fallbackSvg(string $seed, string $label = 'Avatar'): string
{
if ($variant === 'none') {
return '';
}
$seed = self::normalizeSeed($seed);
$initials = strtoupper(substr(preg_replace('/[^a-z]/', '', $seed) ?: 'pk', 0, 2));
$palette = ['#f5efe5', '#d9c7b3', '#8f6f54', '#37485a', '#c88f5b'];
$hash = abs((int)crc32($seed));
$bg = $palette[$hash % count($palette)];
$fg = $hash % 2 === 0 ? '#243142' : '#ffffff';
$file = self::layerFilePath($category, $variant);
if (!is_file($file)) {
return '';
}
$svg = file_get_contents($file);
if ($svg === false) {
return '';
}
return '<span class="pkt-avatar__layer pkt-avatar__layer--' . htmlspecialchars($category, ENT_QUOTES) . '">' . $svg . '</span>';
}
public static function layerFilePath(string $category, string $variant): string
{
return dirname(__DIR__, 2) . '/public/assets/avatars/layers/modern/' . $category . '/' . $variant . '.svg';
}
public static function presetPublicPath(string $preset): ?string
{
$preset = preg_replace('/[^a-z0-9_]/', '', strtolower($preset)) ?: '';
$file = dirname(__DIR__, 2) . '/public/assets/avatars/presets/' . $preset . '.png';
if (!is_file($file)) {
return null;
}
return '/assets/avatars/presets/' . $preset . '.png';
return sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="%s"><rect width="128" height="128" rx="28" fill="%s"/><circle cx="64" cy="50" r="24" fill="rgba(255,255,255,0.26)"/><path d="M30 110c4-18 18-30 34-30s30 12 34 30" fill="rgba(255,255,255,0.2)"/><text x="64" y="76" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" font-weight="700" fill="%s">%s</text></svg>',
htmlspecialchars($label, ENT_QUOTES),
htmlspecialchars($bg, ENT_QUOTES),
htmlspecialchars($fg, ENT_QUOTES),
htmlspecialchars($initials, ENT_QUOTES)
);
}
}

View File

@@ -29,7 +29,7 @@ final class ProfileSettings
}
$avatarColumns = [
'avatar_preset' => "ALTER TABLE user_profiles ADD COLUMN avatar_preset VARCHAR(32) NOT NULL DEFAULT 'modern_001' AFTER location_tracking_preference",
'avatar_preset' => "ALTER TABLE user_profiles ADD COLUMN avatar_preset VARCHAR(64) NOT NULL DEFAULT 'papa-kind-treff' AFTER location_tracking_preference",
'avatar_skin_tone' => "ALTER TABLE user_profiles ADD COLUMN avatar_skin_tone VARCHAR(24) NOT NULL DEFAULT 'warm' AFTER location_tracking_preference",
'avatar_hair_style' => "ALTER TABLE user_profiles ADD COLUMN avatar_hair_style VARCHAR(24) NOT NULL DEFAULT 'short' AFTER avatar_skin_tone",
'avatar_hair_color' => "ALTER TABLE user_profiles ADD COLUMN avatar_hair_color VARCHAR(24) NOT NULL DEFAULT 'brown' AFTER avatar_hair_style",