diff --git a/.projektstructure.txt b/.projektstructure.txt index 0404e4f..e80a2fe 100755 --- a/.projektstructure.txt +++ b/.projektstructure.txt @@ -1,5 +1,12 @@ Anweisung: Projektstruktur (Basis-Template) wie in „papa-kind-treff“ +Aktueller Projektstand +- Papa-Kind-Treff nutzt im Mitgliederbereich einen modernen Avatar-Generator auf Layer-Basis. +- Die Avatare bestehen aus getrennten SVG-Ebenen für Kopf, Haare, Bart, Brille, Mütze, Outfit und Farben. +- Relevante Logik liegt in `src/App/Avatar.php`. +- Die Layer-Assets liegen unter `public/assets/avatars/layers/modern/`. +- Änderungen an Funktionen mit Cookies, LocalStorage, SessionStorage, Geolocation, Tracking oder Drittanbietern müssen immer auch in Datenschutz-/Cookie-Hinweisen und im Consent-Flow berücksichtigt werden. + Ziel - Erstelle ein neues Projekt bzw. aktualisiere das aktuelle mit exakt der gleichen Grundstruktur wie in „papa-kind-treff“. - Fokus auf Hauptordner und deren Zweck; keine projektspezifischen Sonderordner (z. B. Community) anlegen. diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index f0ed44e..dafd1d1 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -19,13 +19,13 @@ 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 moderne Preset-Portraits im Stil der Referenz-Assets statt über freie Bild-Uploads erzeugt. +- Profilbilder werden aktuell über einen modernen Layer-Generator mit getrennten SVG-Ebenen statt über freie Bild-Uploads erzeugt. ## Navigation / Konto - Eingeloggte Nutzer sehen rechts ein Profil-Menü statt einzelner `Dashboard`-/`Logout`-Buttons. - Das Profil-Menü enthält aktuell `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen`, `Abmelden`. - Der Mitgliederbereich selbst ist als Layout mit linker Bereichsnavigation aufgebaut. -- Im Profil kann der Nutzer aktuell aus einer hochwertigen modernen Avatar-Preset-Familie wählen. +- Im Profil kann der Nutzer aktuell seinen Avatar über Layer für Kopfform, Haare, Bart, Brille, Mütze, Outfit und Farbwelten zusammenstellen. - Debug ist kein Menüpunkt und erscheint nur als Floating-Käfer für `site_admin` bei aktivem Debug-Modus. ## Community und Rechte diff --git a/README.md b/README.md index 36b3cd2..1e2a134 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale - Impressum, Datenschutz-&-Cookies-Seite und Über-uns - standortbasierte Sortierung für die neuesten Treffen - eigener Einstellungsbereich für Standortfreigabe, Browser-Hinweise und Consent-Verwaltung -- modernes Avatar-System auf Basis hochwertiger Preset-Assets im Stil der Referenzportraits +- moderner Avatar-Generator auf Layer-Basis mit kombinierbaren SVG-Elementen für Kopf, Haare, Bart, Brille, Mütze, Outfit und Farben - 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` diff --git a/partials/landing/account/dashboard.php b/partials/landing/account/dashboard.php index 8eff769..b1684af 100755 --- a/partials/landing/account/dashboard.php +++ b/partials/landing/account/dashboard.php @@ -302,19 +302,118 @@ $sectionLinks = [
-

Wähle ein modernes Preset aus deiner Stilfamilie. Die Vorschau wird sofort aktualisiert.

+

Stell dein Portrait direkt aus Haaren, Bart, Brille, Mütze, Outfit und Farben zusammen. Die Vorschau wird sofort aktualisiert.

- Modernes Avatar-Preset -
- $meta): ?> -
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ Hautfarbe +
+ $meta): ?> + + +
+
+ +
+ Haarfarbe +
+ $meta): ?> + + +
+
+ +
+ Augenfarbe +
+ $meta): ?> + + +
+
+ +
+ Mützenfarbe +
+ $meta): ?> + + +
+
+ +
+ Outfit-Farbe +
+ $meta): ?> +
diff --git a/partials/structure/nav.php b/partials/structure/nav.php index f01c696..302a2d1 100755 --- a/partials/structure/nav.php +++ b/partials/structure/nav.php @@ -11,7 +11,7 @@ if ($isLoggedIn) { if ($pdo) { $profileSettings = new \App\ProfileSettings($pdo); $profileSettings->ensureSchema(); - $stmt = $pdo->prepare('SELECT display_name, first_name, avatar_preset, avatar_skin_tone, avatar_hair_style, avatar_hair_color, avatar_eye_color, avatar_beard_style, avatar_glasses_style, avatar_head_shape, avatar_hat_style, avatar_hat_color, avatar_outfit_color FROM user_profiles WHERE user_id = :id LIMIT 1'); + $stmt = $pdo->prepare('SELECT display_name, first_name, avatar_preset, avatar_skin_tone, avatar_hair_style, avatar_hair_color, avatar_eye_color, avatar_beard_style, avatar_glasses_style, avatar_head_shape, avatar_hat_style, avatar_hat_color, avatar_outfit_style, avatar_outfit_color FROM user_profiles WHERE user_id = :id LIMIT 1'); $stmt->execute(['id' => (int)$_SESSION['user_id']]); $profileRow = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; $displayName = trim((string)($profileRow['display_name'] ?? '')) ?: trim((string)($profileRow['first_name'] ?? '')) ?: 'Profil'; diff --git a/public/assets/avatars/layers/modern/beard/full.svg b/public/assets/avatars/layers/modern/beard/full.svg new file mode 100644 index 0000000..b799da1 --- /dev/null +++ b/public/assets/avatars/layers/modern/beard/full.svg @@ -0,0 +1,4 @@ + diff --git a/public/assets/avatars/layers/modern/beard/mustache.svg b/public/assets/avatars/layers/modern/beard/mustache.svg new file mode 100644 index 0000000..535a8ae --- /dev/null +++ b/public/assets/avatars/layers/modern/beard/mustache.svg @@ -0,0 +1,3 @@ + diff --git a/public/assets/avatars/layers/modern/beard/short.svg b/public/assets/avatars/layers/modern/beard/short.svg new file mode 100644 index 0000000..00c77f2 --- /dev/null +++ b/public/assets/avatars/layers/modern/beard/short.svg @@ -0,0 +1,4 @@ + diff --git a/public/assets/avatars/layers/modern/features/default.svg b/public/assets/avatars/layers/modern/features/default.svg new file mode 100644 index 0000000..6648a42 --- /dev/null +++ b/public/assets/avatars/layers/modern/features/default.svg @@ -0,0 +1,14 @@ + diff --git a/public/assets/avatars/layers/modern/glasses/round.svg b/public/assets/avatars/layers/modern/glasses/round.svg new file mode 100644 index 0000000..fc3b976 --- /dev/null +++ b/public/assets/avatars/layers/modern/glasses/round.svg @@ -0,0 +1,5 @@ + diff --git a/public/assets/avatars/layers/modern/glasses/square.svg b/public/assets/avatars/layers/modern/glasses/square.svg new file mode 100644 index 0000000..014d8a1 --- /dev/null +++ b/public/assets/avatars/layers/modern/glasses/square.svg @@ -0,0 +1,5 @@ + diff --git a/public/assets/avatars/layers/modern/hair/curly.svg b/public/assets/avatars/layers/modern/hair/curly.svg new file mode 100644 index 0000000..059de85 --- /dev/null +++ b/public/assets/avatars/layers/modern/hair/curly.svg @@ -0,0 +1,7 @@ + diff --git a/public/assets/avatars/layers/modern/hair/fade.svg b/public/assets/avatars/layers/modern/hair/fade.svg new file mode 100644 index 0000000..2aaffa8 --- /dev/null +++ b/public/assets/avatars/layers/modern/hair/fade.svg @@ -0,0 +1,4 @@ + diff --git a/public/assets/avatars/layers/modern/hair/parted.svg b/public/assets/avatars/layers/modern/hair/parted.svg new file mode 100644 index 0000000..b98134d --- /dev/null +++ b/public/assets/avatars/layers/modern/hair/parted.svg @@ -0,0 +1,4 @@ + diff --git a/public/assets/avatars/layers/modern/hair/short.svg b/public/assets/avatars/layers/modern/hair/short.svg new file mode 100644 index 0000000..0f7f774 --- /dev/null +++ b/public/assets/avatars/layers/modern/hair/short.svg @@ -0,0 +1,4 @@ + diff --git a/public/assets/avatars/layers/modern/hair/waves.svg b/public/assets/avatars/layers/modern/hair/waves.svg new file mode 100644 index 0000000..ea2d423 --- /dev/null +++ b/public/assets/avatars/layers/modern/hair/waves.svg @@ -0,0 +1,4 @@ + diff --git a/public/assets/avatars/layers/modern/hat/beanie.svg b/public/assets/avatars/layers/modern/hat/beanie.svg new file mode 100644 index 0000000..d5ec582 --- /dev/null +++ b/public/assets/avatars/layers/modern/hat/beanie.svg @@ -0,0 +1,4 @@ + diff --git a/public/assets/avatars/layers/modern/hat/cap.svg b/public/assets/avatars/layers/modern/hat/cap.svg new file mode 100644 index 0000000..aab2d9a --- /dev/null +++ b/public/assets/avatars/layers/modern/hat/cap.svg @@ -0,0 +1,4 @@ + diff --git a/public/assets/avatars/layers/modern/head/oval.svg b/public/assets/avatars/layers/modern/head/oval.svg new file mode 100644 index 0000000..0d19968 --- /dev/null +++ b/public/assets/avatars/layers/modern/head/oval.svg @@ -0,0 +1,8 @@ + diff --git a/public/assets/avatars/layers/modern/head/round.svg b/public/assets/avatars/layers/modern/head/round.svg new file mode 100644 index 0000000..353b967 --- /dev/null +++ b/public/assets/avatars/layers/modern/head/round.svg @@ -0,0 +1,8 @@ + diff --git a/public/assets/avatars/layers/modern/head/square.svg b/public/assets/avatars/layers/modern/head/square.svg new file mode 100644 index 0000000..9bc08b5 --- /dev/null +++ b/public/assets/avatars/layers/modern/head/square.svg @@ -0,0 +1,7 @@ + diff --git a/public/assets/avatars/layers/modern/outfit/crew.svg b/public/assets/avatars/layers/modern/outfit/crew.svg new file mode 100644 index 0000000..fccbace --- /dev/null +++ b/public/assets/avatars/layers/modern/outfit/crew.svg @@ -0,0 +1,4 @@ + diff --git a/public/assets/avatars/layers/modern/outfit/hoodie.svg b/public/assets/avatars/layers/modern/outfit/hoodie.svg new file mode 100644 index 0000000..8e691f8 --- /dev/null +++ b/public/assets/avatars/layers/modern/outfit/hoodie.svg @@ -0,0 +1,5 @@ + diff --git a/public/assets/avatars/layers/modern/outfit/polo.svg b/public/assets/avatars/layers/modern/outfit/polo.svg new file mode 100644 index 0000000..084f1d6 --- /dev/null +++ b/public/assets/avatars/layers/modern/outfit/polo.svg @@ -0,0 +1,5 @@ + diff --git a/public/assets/avatars/layers/modern/outfit/tee.svg b/public/assets/avatars/layers/modern/outfit/tee.svg new file mode 100644 index 0000000..3918e7d --- /dev/null +++ b/public/assets/avatars/layers/modern/outfit/tee.svg @@ -0,0 +1,4 @@ + diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 36730e2..f2e82a2 100755 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -114,84 +114,22 @@ body { .step { text-align: left; } .step__icon { width: 34px; height:34px; border-radius: 10px; background: var(--color-accent); color: #fff; display:flex; align-items:center; justify-content:center; font-weight:700; margin-bottom: 8px; } -.pkt-avatar { --avatar-size: 52px; --avatar-skin: #efc1a3; --avatar-hair: #5b392b; --avatar-eye: #5f4431; --avatar-hat: #38475f; --avatar-outfit: #5f7387; position: relative; display: inline-block; width: var(--avatar-size); height: var(--avatar-size); border-radius: 20px; overflow: hidden; background: #f7efe6; border: 1px solid rgba(74, 52, 38, 0.12); box-shadow: 0 10px 24px rgba(39, 28, 21, 0.08); } +.pkt-avatar { --avatar-size: 52px; --avatar-skin: #efc1a3; --avatar-hair: #5b392b; --avatar-eye: #5f4431; --avatar-hat: #38475f; --avatar-outfit: #5f7387; position: relative; display: inline-block; width: var(--avatar-size); height: var(--avatar-size); border-radius: 20px; overflow: hidden; background: radial-gradient(circle at 50% 22%, #fffdfa 0%, #f7efe6 62%, #ecdac6 100%); border: 1px solid rgba(74, 52, 38, 0.12); box-shadow: 0 10px 24px rgba(39, 28, 21, 0.08); isolation: isolate; } .pkt-avatar--nav { --avatar-size: 34px; border-radius: 999px; } .pkt-avatar--forum-list { --avatar-size: 42px; border-radius: 14px; } .pkt-avatar--forum { --avatar-size: 52px; border-radius: 16px; } .pkt-avatar--xl { --avatar-size: 120px; border-radius: 28px; } .pkt-avatar--xxl { --avatar-size: 168px; border-radius: 36px; } -.pkt-avatar__img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center top; } -.pkt-avatar__svg { display: block; width: 100%; height: 100%; } -.pkt-avatar__bg { fill: url(#none); } -.pkt-avatar__bg { fill: #f7efe6; } -.pkt-avatar__shadow { fill: rgba(70, 52, 40, 0.10); } -.pkt-avatar__outfit { fill: color-mix(in srgb, var(--avatar-outfit) 88%, black 12%); } -.pkt-avatar__outfit-detail { fill: none; stroke: rgba(255,255,255,0.45); stroke-width: 3.5; stroke-linecap: round; } -.pkt-avatar__neck, -.pkt-avatar__ear, -.pkt-avatar__face { fill: var(--avatar-skin); } -.pkt-avatar__face-shade { fill: rgba(88, 58, 35, 0.10); } -.pkt-avatar__cheek { fill: rgba(204, 112, 100, 0.12); } -.pkt-avatar__hair-base, -.pkt-avatar__hair-short, -.pkt-avatar__hair-fade, -.pkt-avatar__hair-parted, -.pkt-avatar__hair-curly, -.pkt-avatar__hair-waves, -.pkt-avatar__beard-mustache, -.pkt-avatar__beard-short, -.pkt-avatar__beard-full, -.pkt-avatar__brow { fill: var(--avatar-hair); stroke: none; } -.pkt-avatar__hair-base { opacity: .15; } -.pkt-avatar__hair-short, -.pkt-avatar__hair-fade, -.pkt-avatar__hair-parted, -.pkt-avatar__hair-curly, -.pkt-avatar__hair-waves { display: none; } -.pkt-avatar--hair-short .pkt-avatar__hair-short, -.pkt-avatar--hair-fade .pkt-avatar__hair-fade, -.pkt-avatar--hair-parted .pkt-avatar__hair-parted, -.pkt-avatar--hair-curly .pkt-avatar__hair-curly, -.pkt-avatar--hair-waves .pkt-avatar__hair-waves { display: block; } -.pkt-avatar--hair-bald .pkt-avatar__hair-base, -.pkt-avatar--hair-bald .pkt-avatar__hair-short, -.pkt-avatar--hair-bald .pkt-avatar__hair-fade, -.pkt-avatar--hair-bald .pkt-avatar__hair-parted, -.pkt-avatar--hair-bald .pkt-avatar__hair-curly, -.pkt-avatar--hair-bald .pkt-avatar__hair-waves { display: none; } -.pkt-avatar__hat-beanie, -.pkt-avatar__hat-beanie-rib, -.pkt-avatar__hat-cap, -.pkt-avatar__hat-cap-brim { display: none; fill: var(--avatar-hat); } -.pkt-avatar__hat-beanie-rib, -.pkt-avatar__hat-cap-brim { fill: color-mix(in srgb, var(--avatar-hat) 86%, black 14%); } -.pkt-avatar--hat-beanie .pkt-avatar__hat-beanie, -.pkt-avatar--hat-beanie .pkt-avatar__hat-beanie-rib, -.pkt-avatar--hat-cap .pkt-avatar__hat-cap, -.pkt-avatar--hat-cap .pkt-avatar__hat-cap-brim { display: block; } -.pkt-avatar__brow { fill: none; stroke: color-mix(in srgb, var(--avatar-hair) 92%, black 8%); stroke-width: 4; stroke-linecap: round; opacity: .9; } -.pkt-avatar__eye-shell { fill: #fff; } -.pkt-avatar__eye { fill: var(--avatar-eye); } -.pkt-avatar__eye-highlight { fill: rgba(255,255,255,0.9); } -.pkt-avatar__nose { fill: none; stroke: rgba(113, 76, 49, 0.18); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; } -.pkt-avatar__mouth { fill: none; stroke: rgba(102, 66, 42, 0.7); stroke-width: 3; stroke-linecap: round; } -.pkt-avatar__beard-mustache, -.pkt-avatar__beard-short, -.pkt-avatar__beard-full { display: none; } -.pkt-avatar--beard-mustache .pkt-avatar__beard-mustache, -.pkt-avatar--beard-short .pkt-avatar__beard-short, -.pkt-avatar--beard-full .pkt-avatar__beard-full { display: block; } -.pkt-avatar__glasses-round-left, -.pkt-avatar__glasses-round-right, -.pkt-avatar__glasses-square-left, -.pkt-avatar__glasses-square-right, -.pkt-avatar__glasses-bridge { display: none; fill: none; stroke: rgba(41, 41, 41, 0.78); stroke-width: 2.8; stroke-linecap: round; } -.pkt-avatar--glasses-round .pkt-avatar__glasses-round-left, -.pkt-avatar--glasses-round .pkt-avatar__glasses-round-right, -.pkt-avatar--glasses-round .pkt-avatar__glasses-bridge { display: block; } -.pkt-avatar--glasses-square .pkt-avatar__glasses-square-left, -.pkt-avatar--glasses-square .pkt-avatar__glasses-square-right, -.pkt-avatar--glasses-square .pkt-avatar__glasses-bridge { display: block; } +.pkt-avatar--layered::before { content: ""; position: absolute; inset: auto 8% -8% 8%; height: 28%; border-radius: 50%; background: radial-gradient(circle at center, rgba(74, 52, 38, 0.16), transparent 70%); filter: blur(10px); z-index: 0; pointer-events: none; } +.pkt-avatar__layer { position: absolute; inset: 0; display: block; z-index: 1; pointer-events: none; } +.pkt-avatar__layer--outfit { z-index: 1; } +.pkt-avatar__layer--head { z-index: 2; } +.pkt-avatar__layer--hair { z-index: 3; } +.pkt-avatar__layer--hat { z-index: 4; } +.pkt-avatar__layer--features { z-index: 5; } +.pkt-avatar__layer--beard { z-index: 6; } +.pkt-avatar__layer--glasses { z-index: 7; } +.pkt-avatar__layer svg { display: block; width: 100%; height: 100%; } .faq details { border:1px solid var(--color-border); border-radius: var(--radius-sm); padding: 10px 12px; background: #fff; } .faq summary { cursor:pointer; font-weight: 600; } @@ -311,18 +249,11 @@ body { .avatar-builder__layout { display: grid; grid-template-columns: 240px minmax(0, 1fr); gap: 24px; align-items: start; } .avatar-builder__preview { position: sticky; top: 0; display: grid; justify-items: center; gap: 12px; padding: 18px; background: linear-gradient(180deg, #fffdfa, #f8f4ec); border: 1px solid var(--color-border); border-radius: 18px; } .avatar-builder__controls { display: grid; gap: 18px; } -.avatar-preset-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 12px; } -.avatar-preset-card { display: grid; gap: 8px; align-content: start; padding: 10px; border: 1px solid var(--color-border); border-radius: 16px; background: #fff; cursor: pointer; transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease; } -.avatar-preset-card:hover { border-color: var(--color-primary); box-shadow: 0 10px 20px rgba(41, 31, 22, 0.08); transform: translateY(-1px); } -.avatar-preset-card input { position: absolute; opacity: 0; pointer-events: none; } -.avatar-preset-card__thumb { display: block; aspect-ratio: 1; border-radius: 14px; overflow: hidden; background: linear-gradient(180deg, #f7efe6, #f0e4d7); border: 1px solid rgba(74, 52, 38, 0.08); } -.avatar-preset-card__thumb img { width: 100%; height: 100%; object-fit: cover; object-position: center top; display: block; } -.avatar-preset-card__label { font-size: 13px; font-weight: 700; color: var(--color-text); } -.avatar-preset-card:has(input:checked) { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(52,72,90,0.14); } .avatar-swatch-group { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; } .avatar-swatch { display: grid; grid-template-columns: 20px 18px minmax(0, 1fr); gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 14px; background: #fff; cursor: pointer; } .avatar-swatch__color { width: 18px; height: 18px; border-radius: 999px; background: var(--swatch-color); border: 1px solid rgba(0,0,0,0.12); } .avatar-swatch input { margin: 0; } +.avatar-swatch:has(input:checked) { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(52,72,90,0.14); } .settings-help { display: grid; gap: 14px; } .settings-help details { border: 1px solid var(--color-border); border-radius: 12px; padding: 12px 14px; background: #fff; } .settings-help summary { cursor: pointer; font-weight: 700; } diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 215b5f6..71ba718 100755 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -732,22 +732,13 @@ document.addEventListener('DOMContentLoaded', () => { 'pkt-avatar--beard-', 'pkt-avatar--glasses-', 'pkt-avatar--hat-', + 'pkt-avatar--outfit-', ]; const updateAvatarPreview = (form) => { const preview = form.querySelector('[data-avatar-preview] .pkt-avatar'); if (!preview) return; - const presetChoice = form.querySelector('[data-avatar-preset-option]:checked'); - if (presetChoice) { - const img = preview.querySelector('.pkt-avatar__img'); - const src = presetChoice.getAttribute('data-avatar-src') || ''; - if (img && src) { - img.src = src; - } - return; - } - const readValue = (field) => { const checked = form.querySelector(`[data-avatar-field="${field}"]:checked`); if (checked) return checked.value; @@ -770,6 +761,7 @@ document.addEventListener('DOMContentLoaded', () => { preview.classList.add(`pkt-avatar--beard-${readValue('avatar_beard_style')}`); preview.classList.add(`pkt-avatar--glasses-${readValue('avatar_glasses_style')}`); preview.classList.add(`pkt-avatar--hat-${readValue('avatar_hat_style')}`); + preview.classList.add(`pkt-avatar--outfit-${readValue('avatar_outfit_style')}`); preview.style.setProperty('--avatar-skin', readColor('avatar_skin_tone')); preview.style.setProperty('--avatar-hair', readColor('avatar_hair_color')); preview.style.setProperty('--avatar-eye', readColor('avatar_eye_color')); @@ -779,7 +771,7 @@ document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('[data-avatar-builder]').forEach((form) => { updateAvatarPreview(form); - form.querySelectorAll('[data-avatar-field], [data-avatar-preset-option]').forEach((field) => { + form.querySelectorAll('[data-avatar-field]').forEach((field) => { field.addEventListener('change', () => updateAvatarPreview(form)); field.addEventListener('input', () => updateAvatarPreview(form)); }); diff --git a/schema.sql b/schema.sql index 4a9068c..94aa157 100755 --- a/schema.sql +++ b/schema.sql @@ -40,6 +40,7 @@ CREATE TABLE user_profiles ( avatar_head_shape VARCHAR(24) NOT NULL DEFAULT 'round', avatar_hat_style VARCHAR(24) NOT NULL DEFAULT 'none', avatar_hat_color VARCHAR(24) NOT NULL DEFAULT 'navy', + avatar_outfit_style VARCHAR(24) NOT NULL DEFAULT 'tee', avatar_outfit_color VARCHAR(24) NOT NULL DEFAULT 'slate', contact_phone VARBINARY(512) NULL, contact_email VARBINARY(512) NULL, diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php index 52cba09..3670e96 100755 --- a/src/App/AccountPages.php +++ b/src/App/AccountPages.php @@ -348,12 +348,13 @@ final class AccountPages 'avatar_head_shape' => 'round', 'avatar_hat_style' => 'none', 'avatar_hat_color' => 'navy', + 'avatar_outfit_style' => 'tee', 'avatar_outfit_color' => 'slate', ]; 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_skin_tone, p.avatar_hair_style, p.avatar_hair_color, p.avatar_eye_color, p.avatar_beard_style, p.avatar_glasses_style, p.avatar_head_shape, p.avatar_hat_style, p.avatar_hat_color, p.avatar_outfit_color FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1'); + $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_skin_tone, p.avatar_hair_style, p.avatar_hair_color, p.avatar_eye_color, p.avatar_beard_style, p.avatar_glasses_style, p.avatar_head_shape, p.avatar_hat_style, p.avatar_hat_color, p.avatar_outfit_style, p.avatar_outfit_color 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) { @@ -420,7 +421,6 @@ final class AccountPages $section = 'profile'; } $avatarOptions = Avatar::options(); - $avatarPresetOptions = Avatar::presetOptions(); return compact( 'flash', @@ -438,7 +438,6 @@ final class AccountPages 'communityCanApply', 'communityRestrictions', 'avatarOptions', - 'avatarPresetOptions', 'section', 'allowedSections' ); diff --git a/src/App/Avatar.php b/src/App/Avatar.php index d3581fb..6b2dae9 100644 --- a/src/App/Avatar.php +++ b/src/App/Avatar.php @@ -10,14 +10,15 @@ final class Avatar return [ 'avatar_preset' => 'modern_01', 'avatar_skin_tone' => 'warm', - 'avatar_hair_style' => 'short', + 'avatar_hair_style' => 'parted', 'avatar_hair_color' => 'brown', 'avatar_eye_color' => 'brown', - 'avatar_beard_style' => 'none', + 'avatar_beard_style' => 'short', 'avatar_glasses_style' => 'none', - 'avatar_head_shape' => 'round', + 'avatar_head_shape' => 'oval', 'avatar_hat_style' => 'none', 'avatar_hat_color' => 'navy', + 'avatar_outfit_style' => 'tee', 'avatar_outfit_color' => 'slate', ]; } @@ -25,34 +26,33 @@ final class Avatar public static function options(): array { return [ - 'avatar_preset' => self::presetOptions(), 'avatar_skin_tone' => [ - 'fair' => ['label' => 'Hell', 'color' => '#f5d7c2'], - 'warm' => ['label' => 'Warm', 'color' => '#efc1a3'], - 'golden' => ['label' => 'Gold', 'color' => '#dca57f'], - 'deep' => ['label' => 'Tief', 'color' => '#a86d49'], - 'rich' => ['label' => 'Kräftig', 'color' => '#7d4d31'], + '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', - 'parted' => 'Seitlich', 'curly' => 'Lockig', - 'waves' => 'Wellen', + 'waves' => 'Wellig', 'bald' => 'Kahl', ], 'avatar_hair_color' => [ - 'black' => ['label' => 'Schwarz', 'color' => '#1d1b1b'], - 'brown' => ['label' => 'Braun', 'color' => '#5b392b'], - 'blonde' => ['label' => 'Blond', 'color' => '#d7b15f'], - 'red' => ['label' => 'Rot', 'color' => '#9e4f35'], - 'gray' => ['label' => 'Grau', 'color' => '#767676'], + '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' => '#5f4431'], - 'hazel' => ['label' => 'Hasel', 'color' => '#7f6845'], - 'green' => ['label' => 'Grün', 'color' => '#54704e'], - 'blue' => ['label' => 'Blau', 'color' => '#4d6e92'], + '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', @@ -76,11 +76,17 @@ final class Avatar 'cap' => 'Cap', ], 'avatar_hat_color' => [ - 'navy' => ['label' => 'Navy', 'color' => '#38475f'], - 'charcoal' => ['label' => 'Anthrazit', 'color' => '#3c3f45'], - 'forest' => ['label' => 'Waldgrün', 'color' => '#48614d'], - 'brick' => ['label' => 'Ziegel', 'color' => '#975244'], - 'sand' => ['label' => 'Sand', 'color' => '#b79e79'], + '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'], @@ -92,29 +98,6 @@ final class Avatar ]; } - public static function presetOptions(): array - { - $options = []; - for ($i = 1; $i <= 30; $i++) { - $id = sprintf('modern_%02d', $i); - $options[$id] = [ - 'label' => 'Modern ' . str_pad((string)$i, 2, '0', STR_PAD_LEFT), - 'src' => self::presetPublicPath($id), - ]; - } - return $options; - } - - public static function presetPublicPath(string $preset): string - { - return '/assets/avatars/presets/' . rawurlencode($preset) . '.png'; - } - - public static function presetFilePath(string $preset): string - { - return dirname(__DIR__, 2) . '/public/assets/avatars/presets/' . $preset . '.png'; - } - public static function selectColumns(string $alias = 'p'): string { $columns = array_keys(self::defaults()); @@ -132,7 +115,7 @@ final class Avatar foreach ($defaults as $key => $defaultValue) { $value = (string)($input[$key] ?? $defaultValue); - if (!array_key_exists($value, $options[$key])) { + if (isset($options[$key]) && !array_key_exists($value, $options[$key])) { $value = $defaultValue; } $normalized[$key] = $value; @@ -143,117 +126,70 @@ final class Avatar public static function render(array $row, string $name = 'Mitglied', string $size = 'md'): string { - static $instanceCounter = 0; - $instanceCounter++; $avatar = self::normalize($row); $options = self::options(); - $preset = (string)($avatar['avatar_preset'] ?? ''); - - if ($preset !== '' && is_file(self::presetFilePath($preset))) { - $classes = [ - 'pkt-avatar', - 'pkt-avatar--' . preg_replace('/[^a-z0-9\-]/', '', strtolower($size)), - 'pkt-avatar--preset', - ]; - - return sprintf( - '', - htmlspecialchars(implode(' ', $classes), ENT_QUOTES), - htmlspecialchars('Avatar von ' . $name, ENT_QUOTES), - htmlspecialchars(self::presetPublicPath($preset), ENT_QUOTES) - ); - } - - $skin = $options['avatar_skin_tone'][$avatar['avatar_skin_tone']]['color']; - $hair = $options['avatar_hair_color'][$avatar['avatar_hair_color']]['color']; - $eyes = $options['avatar_eye_color'][$avatar['avatar_eye_color']]['color']; - $hat = $options['avatar_hat_color'][$avatar['avatar_hat_color']]['color']; - $outfit = $options['avatar_outfit_color'][$avatar['avatar_outfit_color']]['color']; $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;', - $skin, - $hair, - $eyes, - $hat, - $outfit + $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'] ); - $label = htmlspecialchars('Avatar von ' . $name, ENT_QUOTES); - $clipId = 'pktAvatarClip' . $instanceCounter; + $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']), + ]; return sprintf( - '' . - '' . - '', + '%s', htmlspecialchars(implode(' ', $classes), ENT_QUOTES), htmlspecialchars($style, ENT_QUOTES), - $label, - htmlspecialchars($clipId, ENT_QUOTES), - htmlspecialchars($clipId, ENT_QUOTES) + htmlspecialchars('Avatar von ' . $name, ENT_QUOTES), + implode('', array_filter($layers)) ); } + + private static function renderLayer(string $category, string $variant): string + { + if ($variant === 'none') { + return ''; + } + + $file = self::layerFilePath($category, $variant); + if (!is_file($file)) { + return ''; + } + + $svg = file_get_contents($file); + if ($svg === false) { + return ''; + } + + return '' . $svg . ''; + } + + public static function layerFilePath(string $category, string $variant): string + { + return dirname(__DIR__, 2) . '/public/assets/avatars/layers/modern/' . $category . '/' . $variant . '.svg'; + } } diff --git a/src/App/ProfileSettings.php b/src/App/ProfileSettings.php index fe65f5c..0a2874e 100644 --- a/src/App/ProfileSettings.php +++ b/src/App/ProfileSettings.php @@ -39,6 +39,7 @@ final class ProfileSettings 'avatar_head_shape' => "ALTER TABLE user_profiles ADD COLUMN avatar_head_shape VARCHAR(24) NOT NULL DEFAULT 'round' AFTER avatar_glasses_style", 'avatar_hat_style' => "ALTER TABLE user_profiles ADD COLUMN avatar_hat_style VARCHAR(24) NOT NULL DEFAULT 'none' AFTER avatar_head_shape", 'avatar_hat_color' => "ALTER TABLE user_profiles ADD COLUMN avatar_hat_color VARCHAR(24) NOT NULL DEFAULT 'navy' AFTER avatar_hat_style", + 'avatar_outfit_style' => "ALTER TABLE user_profiles ADD COLUMN avatar_outfit_style VARCHAR(24) NOT NULL DEFAULT 'tee' AFTER avatar_hat_color", 'avatar_outfit_color' => "ALTER TABLE user_profiles ADD COLUMN avatar_outfit_color VARCHAR(24) NOT NULL DEFAULT 'slate' AFTER avatar_hat_color", ]; foreach ($avatarColumns as $column => $sql) { @@ -86,27 +87,11 @@ final class ProfileSettings } $this->ensureSchema(); - $preset = (string)($avatarConfig['avatar_preset'] ?? ''); - if ($preset !== '') { - $options = Avatar::presetOptions(); - $preset = array_key_exists($preset, $options) ? $preset : 'modern_01'; - $stmt = $this->pdo->prepare( - 'UPDATE user_profiles - SET avatar_preset = :preset, - updated_at = NOW() - WHERE user_id = :id' - ); - $stmt->execute([ - 'preset' => $preset, - 'id' => $userId, - ]); - return; - } - $avatar = Avatar::normalize($avatarConfig); $stmt = $this->pdo->prepare( 'UPDATE user_profiles - SET avatar_skin_tone = :skin, + SET avatar_preset = :preset, + avatar_skin_tone = :skin, avatar_hair_style = :hairStyle, avatar_hair_color = :hairColor, avatar_eye_color = :eyeColor, @@ -115,11 +100,13 @@ final class ProfileSettings avatar_head_shape = :headShape, avatar_hat_style = :hatStyle, avatar_hat_color = :hatColor, + avatar_outfit_style = :outfitStyle, avatar_outfit_color = :outfitColor, updated_at = NOW() WHERE user_id = :id' ); $stmt->execute([ + 'preset' => $avatar['avatar_preset'], 'skin' => $avatar['avatar_skin_tone'], 'hairStyle' => $avatar['avatar_hair_style'], 'hairColor' => $avatar['avatar_hair_color'], @@ -129,6 +116,7 @@ final class ProfileSettings 'headShape' => $avatar['avatar_head_shape'], 'hatStyle' => $avatar['avatar_hat_style'], 'hatColor' => $avatar['avatar_hat_color'], + 'outfitStyle' => $avatar['avatar_outfit_style'], 'outfitColor' => $avatar['avatar_outfit_color'], 'id' => $userId, ]);