From 0e731478865926a201342ca867ead6435dfcfae2 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Tue, 28 Jul 2026 22:30:32 +0200 Subject: [PATCH] Adsasd --- .projektstructure.txt | 3 +- PROJECT_CONTEXT.md | 7 +- README.md | 8 +- config/avatar.php | 209 +++++++++++++++++++- partials/landing/account/dashboard.php | 102 +++++++++- public/assets/js/app.js | 128 ++++++++++++ src/App/Avatar/AvatarManager.php | 21 ++ src/App/Avatar/Croodles.php | 259 +++++++++++++++++++++++++ src/App/Avatar/Lorelei.php | 127 +++++++++++- 9 files changed, 853 insertions(+), 11 deletions(-) create mode 100644 src/App/Avatar/Croodles.php diff --git a/.projektstructure.txt b/.projektstructure.txt index 91aecdc..fde45d7 100755 --- a/.projektstructure.txt +++ b/.projektstructure.txt @@ -3,11 +3,12 @@ Anweisung: Projektstruktur (Basis-Template) wie in „papa-kind-treff“ Aktueller Projektstand - 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 aktuell aktiven Styles liegen in `src/App/Avatar/Lorelei.php` und `src/App/Avatar/Croodles.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 speichert `avatar_style`, `avatar_seed` und `avatar_config_json` generisch im Profil. +- Der Generator zeigt pro Style auch die von DiceBear unterstützten Farbfelder inklusive Fuellung, Stopps und Winkel und speichert diese im gleichen Config-JSON. - Gespeicherte Benutzer-Avatare werden lokal unter `public/assets/avatars/users/ptk-.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. diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index b6d91f0..83661ac 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -12,8 +12,9 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l - MySQL/MariaDB-Schema in `schema.sql` - Globales Frontend vor allem über `public/assets/js/app.js` und `public/assets/css/app.css` - Avatar-Manager liegt zentral unter `src/App/Avatar/AvatarManager.php` -- Der aktive Style `Lorelei` liegt getrennt unter `src/App/Avatar/Lorelei.php` +- Die aktiven Styles `Lorelei` und `Croodles` liegen getrennt unter `src/App/Avatar/Lorelei.php` und `src/App/Avatar/Croodles.php` - Die Avatar-Konfiguration liegt in `config/avatar.php` +- In `config/avatar.php` sind pro Style auch Beispielpaletten, Komponentenlisten und Beispielprofile dokumentiert - Der Avatar-Endpunkt ist als direkte Datei unter `public/api/avatar.php` angebunden und laeuft bewusst ohne vollen App-Bootstrap. ## Wichtige Produktentscheidungen @@ -23,9 +24,9 @@ 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 die Mehr-Style-Architektur serverseitig erzeugt; aktiv ist derzeit `lorelei`. +- Profilbilder werden aktuell über die Mehr-Style-Architektur serverseitig erzeugt; aktiv sind derzeit `lorelei` und `croodles`. - Stil, Seed und Varianten werden generisch im Profil gespeichert; das fertige SVG wird lokal pro Benutzer unter `ptk-.svg` abgelegt. -- Der Avatar-Editor bietet visuelle Komponenten-Vorschauen statt reiner Text-Dropdowns und laedt nur den aktiven Style/Tab nach. +- Der Avatar-Editor bietet visuelle Komponenten-Vorschauen statt reiner Text-Dropdowns, laedt nur den aktiven Style/Tab nach und zeigt pro Style die unterstützten Farb-, Fuellungs-, Stopps- und Winkelparameter an. ## Navigation / Konto - Eingeloggte Nutzer sehen rechts ein Profil-Menü statt einzelner `Dashboard`-/`Logout`-Buttons. diff --git a/README.md b/README.md index 1ba55f0..c98ab5f 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,10 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale - standortbasierte Sortierung für die neuesten Treffen - eigener Einstellungsbereich für Standortfreigabe, Browser-Hinweise und Consent-Verwaltung - 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` +- aktive Avatar-Styles aktuell: `lorelei` und `croodles`; weitere Styles können über `config/avatar.php` freigeschaltet werden +- Profilbild-Generator mit visueller Komponenten-Auswahl, generischem Style-/Seed-/Config-Speichermodell, Style-Wechsel und vollständigen Farb-, Füllungs-, Stopps- und Winkeloptionen pro Style - jedes gespeicherte Profilbild wird als lokale Benutzer-SVG unter `public/assets/avatars/users/ptk-.svg` abgelegt +- `config/avatar.php` enthaelt jetzt pro Style auch explizite Beispielpaletten und Beispielprofile als Einbindungsreferenz - 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` @@ -41,11 +42,12 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale Die Rechteverteilung und Community-Logik sind in `src/App/CommunityAccess.php` abgebildet. Die zentrale Avatar-Verwaltung liegt in `src/App/Avatar/AvatarManager.php`. -Der aktuell aktive Style `Lorelei` liegt in `src/App/Avatar/Lorelei.php`. +Die aktuell aktiven Styles liegen in `src/App/Avatar/Lorelei.php` und `src/App/Avatar/Croodles.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 Styles und Tabs geladen; weitere Varianten werden erst beim Wechsel nachgeladen. +Farbfelder werden pro Style direkt im Generator gepflegt und zusammen mit Stil und Seed im Profil gespeichert. ## Consent und rechtlich relevante Integrationen Der aktuelle Code nutzt oder kann nutzen: diff --git a/config/avatar.php b/config/avatar.php index 6851022..d4aa51f 100644 --- a/config/avatar.php +++ b/config/avatar.php @@ -16,13 +16,220 @@ return [ 'default_style' => 'lorelei', 'enabled_styles' => [ 'lorelei', + 'croodles', ], 'styles' => [ 'lorelei' => [ 'label' => 'Lorelei', 'class' => \App\Avatar\Lorelei::class, 'fixed_colors' => [], - 'color_controls' => [], + 'component_controls' => [ + 'eyesVariant', + 'eyebrowsVariant', + 'mouthVariant', + 'glassesVariant', + 'hairVariant', + 'beardVariant', + 'earringsVariant', + ], + 'color_controls' => [ + 'earringsColor' => [ + 'label' => 'Ohrringe', + 'default' => '#d6b36b', + 'samples' => ['#d6b36b', '#b08d57', '#d9d9d9', '#8c6c4a'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'eyebrowsColor' => [ + 'label' => 'Augenbrauen', + 'default' => '#4a3227', + 'samples' => ['#4a3227', '#6a4938', '#2c221d', '#8a6651'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'eyesColor' => [ + 'label' => 'Augen', + 'default' => '#2a2522', + 'samples' => ['#2a2522', '#3f4f66', '#6b4d35', '#1f3a5f'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'frecklesColor' => [ + 'label' => 'Sommersprossen', + 'default' => '#b67852', + 'samples' => ['#b67852', '#a16645', '#c48a66', '#8f5a3e'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'glassesColor' => [ + 'label' => 'Brille', + 'default' => '#26313f', + 'samples' => ['#26313f', '#4d5f73', '#1b1f27', '#7a5d42'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'hairColor' => [ + 'label' => 'Haare', + 'default' => '#4b3128', + 'samples' => ['#4b3128', '#6f4b38', '#221916', '#b38a52'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'hairAccessoriesColor' => [ + 'label' => 'Haar-Accessoires', + 'default' => '#b96f54', + 'samples' => ['#b96f54', '#c98664', '#8d5a9e', '#5e8aa8'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'mouthColor' => [ + 'label' => 'Mund', + 'default' => '#9f5a56', + 'samples' => ['#9f5a56', '#b66b66', '#804444', '#c98a7a'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'noseColor' => [ + 'label' => 'Nase', + 'default' => '#a56f58', + 'samples' => ['#a56f58', '#bf8569', '#8d5f49', '#d3a17b'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'skinColor' => [ + 'label' => 'Haut', + 'default' => '#e7ba9a', + 'samples' => ['#f4d2ba', '#e7ba9a', '#c98f69', '#8a5638'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'backgroundColor' => [ + 'label' => 'Hintergrund', + 'default' => '#f5efe5', + 'samples' => ['#f5efe5', '#e9dfd1', '#d7e6ef', '#efe2d2'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + ], + 'example_profile' => [ + 'seed' => 'pkt-demo-lorelei', + 'components' => [ + 'eyesVariant' => 'variant08', + 'eyebrowsVariant' => 'variant04', + 'mouthVariant' => 'happy06', + 'glassesVariant' => 'variant02', + 'hairVariant' => 'variant18', + 'beardVariant' => 'variant01', + 'earringsVariant' => '', + ], + 'colors' => [ + 'skinColor' => '#e7ba9a', + 'hairColor' => '#4b3128', + 'eyesColor' => '#2a2522', + 'backgroundColor' => '#f5efe5', + ], + ], + ], + 'croodles' => [ + 'label' => 'Croodles', + 'class' => \App\Avatar\Croodles::class, + 'fixed_colors' => [], + 'component_controls' => [ + 'headVariant', + 'eyesVariant', + 'noseVariant', + 'mouthVariant', + 'topVariant', + 'beardVariant', + 'mustacheVariant', + ], + 'color_controls' => [ + 'baseColor' => [ + 'label' => 'Basisfarbe', + 'default' => '#e7ba9a', + 'samples' => ['#f4d2ba', '#e7ba9a', '#c98f69', '#8a5638'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'eyepatchColor' => [ + 'label' => 'Augenklappe', + 'default' => '#283341', + 'samples' => ['#283341', '#1a222d', '#4a5a6f', '#77583f'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'glassesColor' => [ + 'label' => 'Brille', + 'default' => '#283341', + 'samples' => ['#283341', '#4a5a6f', '#1a222d', '#74614a'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'topColor' => [ + 'label' => 'Top-Farbe', + 'default' => '#4f6ea8', + 'samples' => ['#4f6ea8', '#6d8fc2', '#36507a', '#6f8c55'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + 'backgroundColor' => [ + 'label' => 'Hintergrund', + 'default' => '#f2eadf', + 'samples' => ['#f2eadf', '#e6dccd', '#d9e8ef', '#efe0d0'], + 'allow_custom' => true, + 'allow_fill' => true, + 'allow_fill_stops' => true, + 'allow_angle' => true, + ], + ], + 'example_profile' => [ + 'seed' => 'pkt-demo-croodles', + 'components' => [ + 'headVariant' => 'variant03', + 'eyesVariant' => 'variant06', + 'noseVariant' => 'variant04', + 'mouthVariant' => 'variant09', + 'topVariant' => 'variant11', + 'beardVariant' => 'variant02', + 'mustacheVariant' => '', + ], + 'colors' => [ + 'baseColor' => '#e7ba9a', + 'topColor' => '#4f6ea8', + 'glassesColor' => '#283341', + 'backgroundColor' => '#f2eadf', + ], + ], ], ], ]; diff --git a/partials/landing/account/dashboard.php b/partials/landing/account/dashboard.php index 9cc0d04..a486432 100755 --- a/partials/landing/account/dashboard.php +++ b/partials/landing/account/dashboard.php @@ -5,7 +5,6 @@ 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'; @@ -382,6 +381,107 @@ $sectionLinks = [ + + + + diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 0255c96..b47270b 100755 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -729,6 +729,7 @@ document.addEventListener('DOMContentLoaded', () => { const avatarStyleConfigs = { lorelei: { fields: ['eyesVariant', 'eyebrowsVariant', 'mouthVariant', 'glassesVariant', 'hairVariant', 'beardVariant', 'earringsVariant'], + colorFields: ['earringsColor', 'eyebrowsColor', 'eyesColor', 'frecklesColor', 'glassesColor', 'hairColor', 'hairAccessoriesColor', 'mouthColor', 'noseColor', 'skinColor', 'backgroundColor'], paramMap: { eyesVariant: 'eyesVariant', eyebrowsVariant: 'eyebrowsVariant', @@ -747,6 +748,29 @@ document.addEventListener('DOMContentLoaded', () => { hairVariant: 'hairProbability', }, }, + croodles: { + fields: ['headVariant', 'eyesVariant', 'noseVariant', 'mouthVariant', 'topVariant', 'beardVariant', 'mustacheVariant'], + colorFields: ['baseColor', 'eyepatchColor', 'glassesColor', 'topColor', 'backgroundColor'], + paramMap: { + headVariant: 'headVariant', + eyesVariant: 'eyesVariant', + noseVariant: 'noseVariant', + mouthVariant: 'mouthVariant', + topVariant: 'topVariant', + beardVariant: 'beardVariant', + mustacheVariant: 'mustacheVariant', + }, + optionalProbabilityMap: { + headVariant: 'headProbability', + eyesVariant: 'eyesProbability', + noseVariant: 'noseProbability', + mouthVariant: 'mouthProbability', + topVariant: 'topProbability', + beardVariant: 'beardProbability', + mustacheVariant: 'mustacheProbability', + }, + alwaysProbabilityMap: {}, + }, }; const getCurrentAvatarStyle = (form) => { @@ -769,6 +793,20 @@ document.addEventListener('DOMContentLoaded', () => { return input ? input.value : ''; }; + const readAvatarColorValue = (form, style, field) => { + const panel = getAvatarStylePanel(form, style); + if (!panel) return ''; + const input = panel.querySelector(`[data-avatar-color-input="${field}"]`); + return input ? input.value : ''; + }; + + const readAvatarColorMetaValue = (form, style, field, type) => { + const panel = getAvatarStylePanel(form, style); + if (!panel) return ''; + const input = panel.querySelector(`[data-avatar-color-meta="${field}"][data-avatar-color-meta-type="${type}"]`); + return input ? input.value : ''; + }; + const getAvatarStyleConfig = (style) => { if (avatarStyleConfigs[style]) return avatarStyleConfigs[style]; return avatarStyleConfigs.lorelei; @@ -801,6 +839,27 @@ document.addEventListener('DOMContentLoaded', () => { if (value && param) params.set(param, value); }); + (styleConfig.colorFields || []).forEach((field) => { + const value = Object.prototype.hasOwnProperty.call(overrides, field) + ? overrides[field] + : readAvatarColorValue(form, style, field); + if (value) { + params.set(field, value); + const fill = Object.prototype.hasOwnProperty.call(overrides, `${field}Fill`) + ? overrides[`${field}Fill`] + : readAvatarColorMetaValue(form, style, field, 'fill'); + const fillStops = Object.prototype.hasOwnProperty.call(overrides, `${field}FillStops`) + ? overrides[`${field}FillStops`] + : readAvatarColorMetaValue(form, style, field, 'fillStops'); + const angle = Object.prototype.hasOwnProperty.call(overrides, `${field}Angle`) + ? overrides[`${field}Angle`] + : readAvatarColorMetaValue(form, style, field, 'angle'); + if (fill) params.set(`${field}Fill`, fill); + if (fillStops) params.set(`${field}FillStops`, fillStops); + if (angle) params.set(`${field}Angle`, angle); + } + }); + return params; }; @@ -871,6 +930,72 @@ document.addEventListener('DOMContentLoaded', () => { }); }); + form.querySelectorAll('[data-avatar-color-picker]').forEach((picker) => { + const syncColor = () => { + const style = picker.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form); + const field = picker.getAttribute('data-avatar-color-picker') || ''; + const panel = getAvatarStylePanel(form, style); + if (!panel || !field) return; + const hidden = panel.querySelector(`[data-avatar-color-input="${field}"]`); + const label = panel.querySelector(`[data-avatar-color-label="${style}:${field}"]`); + if (hidden) { + hidden.value = picker.value; + } + if (label) { + label.textContent = picker.value; + } + ensureAvatarBuilderLoaded(form, style); + updateAvatarPreview(form); + updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style)); + }; + + picker.addEventListener('input', syncColor); + picker.addEventListener('change', syncColor); + }); + + form.querySelectorAll('[data-avatar-color-reset]').forEach((button) => { + button.addEventListener('click', () => { + const style = button.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form); + const field = button.getAttribute('data-avatar-color-reset') || ''; + const panel = getAvatarStylePanel(form, style); + if (!panel || !field) return; + const hidden = panel.querySelector(`[data-avatar-color-input="${field}"]`); + const picker = panel.querySelector(`[data-avatar-color-picker="${field}"]`); + const label = panel.querySelector(`[data-avatar-color-label="${style}:${field}"]`); + const fill = panel.querySelector(`[data-avatar-color-meta="${field}"][data-avatar-color-meta-type="fill"]`); + const fillStops = panel.querySelector(`[data-avatar-color-meta="${field}"][data-avatar-color-meta-type="fillStops"]`); + const angle = panel.querySelector(`[data-avatar-color-meta="${field}"][data-avatar-color-meta-type="angle"]`); + const defaultValue = picker?.getAttribute('data-avatar-color-default') || '#c4a484'; + if (hidden) { + hidden.value = ''; + } + if (picker) { + picker.value = defaultValue; + } + if (label) { + label.textContent = 'DiceBear-Standard'; + } + if (fill) fill.value = ''; + if (fillStops) fillStops.value = ''; + if (angle) angle.value = ''; + ensureAvatarBuilderLoaded(form, style); + updateAvatarPreview(form); + updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style)); + }); + }); + + form.querySelectorAll('[data-avatar-color-meta]').forEach((input) => { + const syncMeta = () => { + const style = input.getAttribute('data-avatar-style') || getCurrentAvatarStyle(form); + ensureAvatarBuilderLoaded(form, style); + updateAvatarPreview(form); + updateAvatarOptionPreviews(form, style, getActiveAvatarField(form, style)); + }; + + input.addEventListener('input', syncMeta); + input.addEventListener('change', syncMeta); + }); + const setActiveComponentPanel = (style, field) => { const panel = getAvatarStylePanel(form, style); if (!panel) return; @@ -893,6 +1018,9 @@ document.addEventListener('DOMContentLoaded', () => { panel.querySelectorAll('[data-avatar-field]').forEach((input) => { input.disabled = !active; }); + panel.querySelectorAll('[data-avatar-color-input], [data-avatar-color-picker], [data-avatar-color-reset]').forEach((input) => { + input.disabled = !active; + }); }); ensureAvatarBuilderLoaded(form, style); setActiveComponentPanel(style, getActiveAvatarField(form, style)); diff --git a/src/App/Avatar/AvatarManager.php b/src/App/Avatar/AvatarManager.php index d3c7232..8a4734e 100644 --- a/src/App/Avatar/AvatarManager.php +++ b/src/App/Avatar/AvatarManager.php @@ -66,6 +66,13 @@ final class AvatarManager 'fixed_colors' => [], 'color_controls' => [], ]; + $result['croodles'] = [ + 'key' => 'croodles', + 'label' => Croodles::label(), + 'class' => Croodles::class, + 'fixed_colors' => [], + 'color_controls' => [], + ]; } return $result; @@ -222,10 +229,24 @@ final class AvatarManager ]; } + $colors = []; + foreach ($styleClass::colorDefinitions() as $field => $definition) { + $colors[$field] = [ + 'field' => $field, + 'label' => (string)($definition['label'] ?? $field), + 'value' => (string)($styleConfig[$field] ?? ''), + 'default' => (string)($definition['default'] ?? '#c4a484'), + 'fill' => (string)($styleConfig[$field . 'Fill'] ?? ''), + 'fill_stops' => (string)($styleConfig[$field . 'FillStops'] ?? ''), + 'angle' => (string)($styleConfig[$field . 'Angle'] ?? ''), + ]; + } + $styles[$meta['key']] = [ 'key' => $meta['key'], 'label' => $meta['label'], 'components' => $components, + 'colors' => $colors, ]; } diff --git a/src/App/Avatar/Croodles.php b/src/App/Avatar/Croodles.php new file mode 100644 index 0000000..e310ac2 --- /dev/null +++ b/src/App/Avatar/Croodles.php @@ -0,0 +1,259 @@ + '', + 'eyesVariant' => '', + 'headVariant' => '', + 'mouthVariant' => '', + 'mustacheVariant' => '', + 'noseVariant' => '', + 'topVariant' => '', + 'baseColor' => '', + 'eyepatchColor' => '', + 'glassesColor' => '', + 'topColor' => '', + 'backgroundColor' => '', + ], self::colorCompanionDefaults()); + } + + public static function options(): array + { + return [ + 'beardVariant' => ['' => 'Keine'] + self::variantOptions(5), + 'eyesVariant' => ['' => 'Keine'] + self::variantOptions(16), + 'headVariant' => ['' => 'Keine'] + self::variantOptions(8), + 'mouthVariant' => ['' => 'Keine'] + self::variantOptions(18), + 'mustacheVariant' => ['' => 'Keine'] + self::variantOptions(4), + 'noseVariant' => ['' => 'Keine'] + self::variantOptions(9), + 'topVariant' => ['' => 'Keine'] + self::variantOptions(29), + ]; + } + + public static function componentDefinitions(): array + { + return [ + 'headVariant' => ['label' => 'Kopf'], + 'eyesVariant' => ['label' => 'Augen'], + 'noseVariant' => ['label' => 'Nase'], + 'mouthVariant' => ['label' => 'Mund'], + 'topVariant' => ['label' => 'Top'], + 'beardVariant' => ['label' => 'Bart'], + 'mustacheVariant' => ['label' => 'Schnurrbart'], + ]; + } + + public static function colorDefinitions(): array + { + return [ + 'baseColor' => ['label' => 'Basisfarbe', 'default' => '#e7ba9a'], + 'eyepatchColor' => ['label' => 'Augenklappe', 'default' => '#283341'], + 'glassesColor' => ['label' => 'Brille', 'default' => '#283341'], + 'topColor' => ['label' => 'Top-Farbe', 'default' => '#4f6ea8'], + 'backgroundColor' => ['label' => 'Hintergrund', 'default' => '#f2eadf'], + ]; + } + + 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::defaultSeed(); + } + + return substr($seed, 0, 100); + } + + public static function normalizeConfig(array $input): array + { + $defaults = self::defaultConfig(); + $normalized = []; + + foreach ($defaults as $key => $defaultValue) { + if (array_key_exists($key, self::colorDefinitions())) { + $normalized[$key] = self::normalizeColor((string)($input[$key] ?? $defaultValue)); + continue; + } + if (str_ends_with($key, 'Fill')) { + $normalized[$key] = self::normalizeFill((string)($input[$key] ?? $defaultValue)); + continue; + } + if (str_ends_with($key, 'FillStops')) { + $normalized[$key] = self::normalizeFillStops($input[$key] ?? $defaultValue); + continue; + } + if (str_ends_with($key, 'Angle')) { + $normalized[$key] = self::normalizeAngle($input[$key] ?? $defaultValue); + continue; + } + + $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)]; + $componentFields = array_keys(self::componentDefinitions()); + + foreach ($componentFields as $field) { + $probability = preg_replace('/Variant$/', 'Probability', $field); + if ($config[$field] === '') { + $query[$probability] = '0'; + continue; + } + + $query[$field] = $config[$field]; + $query[$probability] = '100'; + } + + foreach (array_keys(self::colorDefinitions()) as $field) { + if ($config[$field] !== '') { + $query[$field] = $config[$field]; + foreach (self::colorCompanionFields($field) as $companionField) { + if ($config[$companionField] !== '') { + $query[$companionField] = $config[$companionField]; + } + } + } + } + + return $query; + } + + public static function queryToConfig(array $query): array + { + $config = self::defaultConfig(); + + foreach (array_keys(self::componentDefinitions()) as $field) { + $config[$field] = self::normalizeVariant((string)($query[$field] ?? '')); + $probability = (int)($query[preg_replace('/Variant$/', 'Probability', $field)] ?? 100); + if ($probability <= 0) { + $config[$field] = ''; + } + } + + foreach (array_keys(self::colorDefinitions()) as $field) { + $config[$field] = self::normalizeColor((string)($query[$field] ?? '')); + foreach (self::colorCompanionFields($field) as $companionField) { + $config[$companionField] = (string)($query[$companionField] ?? ''); + } + } + + return self::normalizeConfig($config); + } + + public static function fallbackSvg(string $seed, string $label = 'Avatar'): string + { + return Lorelei::fallbackSvg($seed, $label); + } + + private static function normalizeVariant(string $value): string + { + $value = strtolower(trim($value)); + if ($value === '') { + return ''; + } + + return preg_match('/^variant\d{2}$/', $value) === 1 ? $value : ''; + } + + private static function normalizeColor(string $value): string + { + $value = strtolower(trim($value)); + if ($value === '') { + return ''; + } + + return preg_match('/^#[0-9a-f]{6}$/', $value) === 1 ? $value : ''; + } + + private static function normalizeFill(string $value): string + { + $value = strtolower(trim($value)); + if ($value === '') { + return ''; + } + + return in_array($value, ['solid', 'linear', 'radial'], true) ? $value : ''; + } + + private static function normalizeFillStops(mixed $value): string + { + if ($value === '' || $value === null) { + return ''; + } + + $number = max(2, min(8, (int)$value)); + return (string)$number; + } + + private static function normalizeAngle(mixed $value): string + { + if ($value === '' || $value === null) { + return ''; + } + + $number = max(-360, min(360, (int)$value)); + return (string)$number; + } + + private static function colorCompanionDefaults(): array + { + $defaults = []; + foreach (array_keys(self::colorDefinitions()) as $field) { + foreach (self::colorCompanionFields($field) as $companionField) { + $defaults[$companionField] = ''; + } + } + + return $defaults; + } + + private static function colorCompanionFields(string $field): array + { + return [ + $field . 'Fill', + $field . 'FillStops', + $field . 'Angle', + ]; + } + + private static function variantOptions(int $count): array + { + $options = []; + for ($index = 1; $index <= $count; $index++) { + $key = sprintf('variant%02d', $index); + $options[$key] = 'Variante ' . $index; + } + + return $options; + } +} diff --git a/src/App/Avatar/Lorelei.php b/src/App/Avatar/Lorelei.php index b4ceff6..653a7fe 100644 --- a/src/App/Avatar/Lorelei.php +++ b/src/App/Avatar/Lorelei.php @@ -22,7 +22,7 @@ final class Lorelei public static function defaultConfig(): array { - return [ + return array_merge([ 'eyesVariant' => '', 'eyebrowsVariant' => '', 'mouthVariant' => '', @@ -30,7 +30,18 @@ final class Lorelei 'hairVariant' => '', 'beardVariant' => '', 'earringsVariant' => '', - ]; + 'earringsColor' => '', + 'eyebrowsColor' => '', + 'eyesColor' => '', + 'frecklesColor' => '', + 'glassesColor' => '', + 'hairColor' => '', + 'hairAccessoriesColor' => '', + 'mouthColor' => '', + 'noseColor' => '', + 'skinColor' => '', + 'backgroundColor' => '', + ], self::colorCompanionDefaults()); } public static function options(): array @@ -59,6 +70,23 @@ final class Lorelei ]; } + public static function colorDefinitions(): array + { + return [ + 'earringsColor' => ['label' => 'Ohrringe', 'default' => '#d6b36b'], + 'eyebrowsColor' => ['label' => 'Augenbrauen', 'default' => '#4a3227'], + 'eyesColor' => ['label' => 'Augen', 'default' => '#2a2522'], + 'frecklesColor' => ['label' => 'Sommersprossen', 'default' => '#b67852'], + 'glassesColor' => ['label' => 'Brille', 'default' => '#26313f'], + 'hairColor' => ['label' => 'Haare', 'default' => '#4b3128'], + 'hairAccessoriesColor' => ['label' => 'Haar-Accessoires', 'default' => '#b96f54'], + 'mouthColor' => ['label' => 'Mund', 'default' => '#9f5a56'], + 'noseColor' => ['label' => 'Nase', 'default' => '#a56f58'], + 'skinColor' => ['label' => 'Haut', 'default' => '#e7ba9a'], + 'backgroundColor' => ['label' => 'Hintergrund', 'default' => '#f5efe5'], + ]; + } + public static function legacyColumnsFromConfig(string $seed, array $config): array { $config = self::normalizeConfig($config); @@ -107,6 +135,22 @@ final class Lorelei $normalized = []; foreach ($defaults as $key => $defaultValue) { + if (array_key_exists($key, self::colorDefinitions())) { + $normalized[$key] = self::normalizeColor((string)($input[$key] ?? $defaultValue)); + continue; + } + if (str_ends_with($key, 'Fill')) { + $normalized[$key] = self::normalizeFill((string)($input[$key] ?? $defaultValue)); + continue; + } + if (str_ends_with($key, 'FillStops')) { + $normalized[$key] = self::normalizeFillStops($input[$key] ?? $defaultValue); + continue; + } + if (str_ends_with($key, 'Angle')) { + $normalized[$key] = self::normalizeAngle($input[$key] ?? $defaultValue); + continue; + } $normalized[$key] = self::normalizeVariant((string)($input[$key] ?? $defaultValue)); } @@ -149,6 +193,17 @@ final class Lorelei $query[$params['probability']] = '100'; } + foreach (array_keys(self::colorDefinitions()) as $field) { + if ($config[$field] !== '') { + $query[$field] = $config[$field]; + foreach (self::colorCompanionFields($field) as $companionField) { + if ($config[$companionField] !== '') { + $query[$companionField] = $config[$companionField]; + } + } + } + } + return $query; } @@ -182,6 +237,13 @@ final class Lorelei $config['hairVariant'] = ''; } + foreach (array_keys(self::colorDefinitions()) as $field) { + $config[$field] = self::normalizeColor((string)($query[$field] ?? '')); + foreach (self::colorCompanionFields($field) as $companionField) { + $config[$companionField] = (string)($query[$companionField] ?? ''); + } + } + return self::normalizeConfig($config); } @@ -240,4 +302,65 @@ final class Lorelei return preg_match('/^(variant\d{2}|happy\d{2}|sad\d{2})$/', $value) === 1 ? $value : ''; } + + private static function normalizeColor(string $value): string + { + $value = strtolower(trim($value)); + if ($value === '') { + return ''; + } + + return preg_match('/^#[0-9a-f]{6}$/', $value) === 1 ? $value : ''; + } + + private static function normalizeFill(string $value): string + { + $value = strtolower(trim($value)); + if ($value === '') { + return ''; + } + + return in_array($value, ['solid', 'linear', 'radial'], true) ? $value : ''; + } + + private static function normalizeFillStops(mixed $value): string + { + if ($value === '' || $value === null) { + return ''; + } + + $number = max(2, min(8, (int)$value)); + return (string)$number; + } + + private static function normalizeAngle(mixed $value): string + { + if ($value === '' || $value === null) { + return ''; + } + + $number = max(-360, min(360, (int)$value)); + return (string)$number; + } + + private static function colorCompanionDefaults(): array + { + $defaults = []; + foreach (array_keys(self::colorDefinitions()) as $field) { + foreach (self::colorCompanionFields($field) as $companionField) { + $defaults[$companionField] = ''; + } + } + + return $defaults; + } + + private static function colorCompanionFields(string $field): array + { + return [ + $field . 'Fill', + $field . 'FillStops', + $field . 'Angle', + ]; + } }