avatar
All checks were successful
Deploy / deploy (push) Successful in 1m5s

This commit is contained in:
2026-07-24 21:28:35 +02:00
parent 5630ff2308
commit 9a5f1b1a5d
41 changed files with 231 additions and 203 deletions

View File

@@ -48,7 +48,7 @@ Stand: 2026-07-24
- `src/App/ProfileSettings.php`
Persistente Profileinstellungen für Standortfreigabe und Avatar-Merkmale.
- `src/App/Avatar.php`
Avatar-Generator, Optionen und HTML-Rendering im realistischerem Comic-Stil.
Avatar-Presets, Asset-Pfade und Rendering im modernen Portrait-Stil.
- `src/App/Search.php`
Eventsuche inkl. Distanzfilter.
@@ -67,7 +67,7 @@ Stand: 2026-07-24
- Kein eigener Top-Level-Punkt `Termine`; Event-Fokus liegt derzeit auf Startseite und Suche.
- Community mit Kategorien, Boards, Thread-Liste, Thread-Ansicht, Rollen-/Moderationslogik.
- Mitgliederbereich als Bereichslayout mit linker Navigation und separaten Seiten für Profil, Kinder, Termine, Community und Einstellungen.
- Profilbereich mit Avatar-Generator für Haut, Haare, Augen, Bart, Brille, Kopfform, Mütze und Outfit-Farbe.
- Profilbereich mit moderner Avatar-Preset-Auswahl auf Basis echter Portrait-Assets.
- Community-Admin-Bereich für Bewerbungen, Meldungen, Rollen und Migration.
- Impressum, Datenschutz-&-Cookies-Seite und Über-uns vorhanden.
- Standortsortierung auf der Startseite für Treffen in der Nähe.

View File

@@ -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 einen integrierten Avatar-Generator im realistischeren Comic-Stil statt über Bild-Uploads erzeugt.
- Profilbilder werden aktuell über moderne Preset-Portraits im Stil der Referenz-Assets 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 sein Avatar-Bild aus festen Merkmalen wie Gesicht, Haare, Bart, Brille, Mütze und Outfit zusammenstellen.
- Im Profil kann der Nutzer aktuell aus einer hochwertigen modernen Avatar-Preset-Familie wählen.
- Debug ist kein Menüpunkt und erscheint nur als Floating-Käfer für `site_admin` bei aktivem Debug-Modus.
## Community und Rechte

View File

@@ -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
- Avatar-Generator im Profil mit realistischerem Comic-Stil, Haut-, Haar-, Augen-, Bart-, Brillen-, Kopfform-, Mützen- und Outfit-Auswahl
- modernes Avatar-System auf Basis hochwertiger Preset-Assets im Stil der Referenzportraits
- 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`

View File

@@ -302,110 +302,19 @@ $sectionLinks = [
<div data-avatar-preview>
<?= \App\Avatar::render($profile, $profile['display_name'] ?: 'Papa', 'xxl') ?>
</div>
<p class="muted small">Die Vorschau wird sofort aktualisiert.</p>
<p class="muted small">Wähle ein modernes Preset aus deiner Stilfamilie. Die Vorschau wird sofort aktualisiert.</p>
</div>
<div class="avatar-builder__controls">
<div class="stack gap-6">
<label class="label" for="avatarHeadShape">Kopfform</label>
<select id="avatarHeadShape" name="avatar_head_shape" class="select" data-avatar-field="avatar_head_shape">
<?php foreach ($avatarOptions['avatar_head_shape'] as $value => $label): ?>
<option value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" <?= $profile['avatar_head_shape'] === $value ? 'selected' : '' ?>><?= htmlspecialchars((string)$label, ENT_QUOTES) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="stack gap-6">
<label class="label" for="avatarHairStyle">Haarform</label>
<select id="avatarHairStyle" name="avatar_hair_style" class="select" data-avatar-field="avatar_hair_style">
<?php foreach ($avatarOptions['avatar_hair_style'] as $value => $label): ?>
<option value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" <?= $profile['avatar_hair_style'] === $value ? 'selected' : '' ?>><?= htmlspecialchars((string)$label, ENT_QUOTES) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="stack gap-6">
<label class="label" for="avatarBeardStyle">Bart</label>
<select id="avatarBeardStyle" name="avatar_beard_style" class="select" data-avatar-field="avatar_beard_style">
<?php foreach ($avatarOptions['avatar_beard_style'] as $value => $label): ?>
<option value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" <?= $profile['avatar_beard_style'] === $value ? 'selected' : '' ?>><?= htmlspecialchars((string)$label, ENT_QUOTES) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="stack gap-6">
<label class="label" for="avatarGlassesStyle">Brille</label>
<select id="avatarGlassesStyle" name="avatar_glasses_style" class="select" data-avatar-field="avatar_glasses_style">
<?php foreach ($avatarOptions['avatar_glasses_style'] as $value => $label): ?>
<option value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" <?= $profile['avatar_glasses_style'] === $value ? 'selected' : '' ?>><?= htmlspecialchars((string)$label, ENT_QUOTES) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="stack gap-6">
<label class="label" for="avatarHatStyle">Mütze</label>
<select id="avatarHatStyle" name="avatar_hat_style" class="select" data-avatar-field="avatar_hat_style">
<?php foreach ($avatarOptions['avatar_hat_style'] as $value => $label): ?>
<option value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" <?= $profile['avatar_hat_style'] === $value ? 'selected' : '' ?>><?= htmlspecialchars((string)$label, ENT_QUOTES) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="stack gap-6">
<span class="label">Hautfarbe</span>
<div class="avatar-swatch-group">
<?php foreach ($avatarOptions['avatar_skin_tone'] as $value => $meta): ?>
<label class="avatar-swatch">
<input type="radio" name="avatar_skin_tone" value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" data-avatar-field="avatar_skin_tone" data-avatar-color="<?= htmlspecialchars((string)$meta['color'], ENT_QUOTES) ?>" <?= $profile['avatar_skin_tone'] === $value ? 'checked' : '' ?>>
<span class="avatar-swatch__color" style="--swatch-color: <?= htmlspecialchars((string)$meta['color'], ENT_QUOTES) ?>;"></span>
<span><?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="stack gap-6">
<span class="label">Haarfarbe</span>
<div class="avatar-swatch-group">
<?php foreach ($avatarOptions['avatar_hair_color'] as $value => $meta): ?>
<label class="avatar-swatch">
<input type="radio" name="avatar_hair_color" value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" data-avatar-field="avatar_hair_color" data-avatar-color="<?= htmlspecialchars((string)$meta['color'], ENT_QUOTES) ?>" <?= $profile['avatar_hair_color'] === $value ? 'checked' : '' ?>>
<span class="avatar-swatch__color" style="--swatch-color: <?= htmlspecialchars((string)$meta['color'], ENT_QUOTES) ?>;"></span>
<span><?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="stack gap-6">
<span class="label">Augenfarbe</span>
<div class="avatar-swatch-group">
<?php foreach ($avatarOptions['avatar_eye_color'] as $value => $meta): ?>
<label class="avatar-swatch">
<input type="radio" name="avatar_eye_color" value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" data-avatar-field="avatar_eye_color" data-avatar-color="<?= htmlspecialchars((string)$meta['color'], ENT_QUOTES) ?>" <?= $profile['avatar_eye_color'] === $value ? 'checked' : '' ?>>
<span class="avatar-swatch__color" style="--swatch-color: <?= htmlspecialchars((string)$meta['color'], ENT_QUOTES) ?>;"></span>
<span><?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="stack gap-6">
<span class="label">Mützenfarbe</span>
<div class="avatar-swatch-group">
<?php foreach ($avatarOptions['avatar_hat_color'] as $value => $meta): ?>
<label class="avatar-swatch">
<input type="radio" name="avatar_hat_color" value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" data-avatar-field="avatar_hat_color" data-avatar-color="<?= htmlspecialchars((string)$meta['color'], ENT_QUOTES) ?>" <?= $profile['avatar_hat_color'] === $value ? 'checked' : '' ?>>
<span class="avatar-swatch__color" style="--swatch-color: <?= htmlspecialchars((string)$meta['color'], ENT_QUOTES) ?>;"></span>
<span><?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="stack gap-6">
<span class="label">Hoodie / Shirt</span>
<div class="avatar-swatch-group">
<?php foreach ($avatarOptions['avatar_outfit_color'] as $value => $meta): ?>
<label class="avatar-swatch">
<input type="radio" name="avatar_outfit_color" value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" data-avatar-field="avatar_outfit_color" data-avatar-color="<?= htmlspecialchars((string)$meta['color'], ENT_QUOTES) ?>" <?= $profile['avatar_outfit_color'] === $value ? 'checked' : '' ?>>
<span class="avatar-swatch__color" style="--swatch-color: <?= htmlspecialchars((string)$meta['color'], ENT_QUOTES) ?>;"></span>
<span><?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?></span>
<span class="label">Modernes Avatar-Preset</span>
<div class="avatar-preset-grid">
<?php foreach ($avatarPresetOptions as $value => $meta): ?>
<label class="avatar-preset-card">
<input type="radio" name="avatar_preset" value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" data-avatar-preset-option data-avatar-src="<?= htmlspecialchars((string)$meta['src'], ENT_QUOTES) ?>" <?= ($profile['avatar_preset'] ?? 'modern_01') === $value ? 'checked' : '' ?>>
<span class="avatar-preset-card__thumb">
<img src="<?= htmlspecialchars((string)$meta['src'], ENT_QUOTES) ?>" alt="">
</span>
<span class="avatar-preset-card__label"><?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?></span>
</label>
<?php endforeach; ?>
</div>

View File

@@ -11,7 +11,7 @@ if ($isLoggedIn) {
if ($pdo) {
$profileSettings = new \App\ProfileSettings($pdo);
$profileSettings->ensureSchema();
$stmt = $pdo->prepare('SELECT display_name, first_name, 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_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';

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -114,87 +114,84 @@ 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: radial-gradient(circle at 28% 22%, #ffffff, #f5ece2 34%, #dfd0c0 100%); border: 1px solid rgba(74, 52, 38, 0.14); box-shadow: inset 0 1px 0 rgba(255,255,255,0.85), 0 10px 22px 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: #f7efe6; border: 1px solid rgba(74, 52, 38, 0.12); box-shadow: 0 10px 24px rgba(39, 28, 21, 0.08); }
.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__bg,
.pkt-avatar__shadow,
.pkt-avatar__hoodie,
.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__ears,
.pkt-avatar__head,
.pkt-avatar__cheek,
.pkt-avatar__hair,
.pkt-avatar__hat,
.pkt-avatar__eye,
.pkt-avatar__brow,
.pkt-avatar__nose,
.pkt-avatar__mouth,
.pkt-avatar__beard,
.pkt-avatar__glasses { position: absolute; display: block; }
.pkt-avatar__bg { inset: 0; background: radial-gradient(circle at 30% 16%, rgba(255,255,255,0.92), rgba(255,255,255,0.04) 42%), radial-gradient(circle at 78% 82%, rgba(93,70,48,0.08), rgba(93,70,48,0) 34%); }
.pkt-avatar__shadow { left: 18%; top: 18%; width: 64%; height: 62%; border-radius: 44% 44% 42% 42%; background: radial-gradient(circle at 52% 56%, rgba(44, 29, 21, 0.20), rgba(44, 29, 21, 0) 68%); filter: blur(calc(var(--avatar-size) * 0.045)); }
.pkt-avatar__hoodie { left: 7%; bottom: -4%; width: 86%; height: 38%; border-radius: 42% 42% 16% 16%; background: linear-gradient(180deg, color-mix(in srgb, var(--avatar-outfit) 82%, white 18%), color-mix(in srgb, var(--avatar-outfit) 72%, black 28%)); }
.pkt-avatar__hoodie::before { content: ""; position: absolute; left: 29%; top: -18%; width: 42%; height: 54%; border-radius: 50% 50% 42% 42%; border: calc(var(--avatar-size) * 0.035) solid color-mix(in srgb, var(--avatar-outfit) 78%, white 22%); border-bottom: 0; opacity: .95; }
.pkt-avatar__hoodie::after { content: ""; position: absolute; left: 47%; top: 12%; width: 6%; height: 20%; border-radius: 999px; background: rgba(255,255,255,0.42); box-shadow: calc(var(--avatar-size) * -0.08) 0 0 rgba(255,255,255,0.42); }
.pkt-avatar__neck { left: 43%; bottom: 24%; width: 14%; height: 16%; border-radius: 10px; background: linear-gradient(180deg, color-mix(in srgb, var(--avatar-skin) 90%, white 10%), color-mix(in srgb, var(--avatar-skin) 80%, black 20%)); }
.pkt-avatar__ears { left: 20%; top: 37%; width: 60%; height: 18%; }
.pkt-avatar__ears::before,
.pkt-avatar__ears::after { content: ""; position: absolute; top: 0; width: 12%; height: 100%; border-radius: 999px; background: linear-gradient(180deg, color-mix(in srgb, var(--avatar-skin) 90%, white 10%), color-mix(in srgb, var(--avatar-skin) 78%, black 22%)); }
.pkt-avatar__ears::before { left: -6%; }
.pkt-avatar__ears::after { right: -6%; }
.pkt-avatar__head { left: 22%; top: 19%; width: 56%; height: 54%; background: radial-gradient(circle at 44% 26%, color-mix(in srgb, var(--avatar-skin) 88%, white 12%) 0, color-mix(in srgb, var(--avatar-skin) 92%, white 8%) 35%, color-mix(in srgb, var(--avatar-skin) 84%, black 16%) 100%); box-shadow: inset 0 calc(var(--avatar-size) * -0.02) calc(var(--avatar-size) * 0.03) rgba(255,255,255,0.22), inset 0 calc(var(--avatar-size) * -0.04) calc(var(--avatar-size) * 0.08) rgba(118, 75, 47, 0.12); }
.pkt-avatar--head-round .pkt-avatar__head { border-radius: 48% 48% 42% 42%; }
.pkt-avatar--head-oval .pkt-avatar__head { border-radius: 44% 44% 46% 46%; transform: scaleX(0.94); }
.pkt-avatar--head-square .pkt-avatar__head { border-radius: 26% 26% 32% 32%; }
.pkt-avatar__cheek { top: 54%; width: 11%; height: 6%; border-radius: 999px; background: rgba(194, 109, 97, 0.16); filter: blur(calc(var(--avatar-size) * 0.018)); }
.pkt-avatar__cheek--left { left: 29%; }
.pkt-avatar__cheek--right { right: 29%; }
.pkt-avatar__hair { left: 17%; top: 8%; width: 66%; height: 30%; background: linear-gradient(180deg, color-mix(in srgb, var(--avatar-hair) 92%, white 8%), color-mix(in srgb, var(--avatar-hair) 72%, black 28%)); border-radius: 52% 52% 26% 26%; box-shadow: inset 0 calc(var(--avatar-size) * -0.015) calc(var(--avatar-size) * 0.04) rgba(255,255,255,0.14); }
.pkt-avatar__hair::before,
.pkt-avatar__hair::after { content: ""; position: absolute; background: var(--avatar-hair); }
.pkt-avatar--hair-short .pkt-avatar__hair::before { left: 10%; right: 10%; top: 52%; height: 36%; border-radius: 0 0 40% 40%; }
.pkt-avatar--hair-fade .pkt-avatar__hair::before { left: 12%; right: 12%; top: 48%; height: 28%; border-radius: 0 0 38% 38%; }
.pkt-avatar--hair-fade .pkt-avatar__hair::after { left: 6%; right: 6%; top: 66%; height: 12%; border-radius: 0 0 50% 50%; opacity: .72; }
.pkt-avatar--hair-parted .pkt-avatar__hair::before { left: 8%; width: 36%; top: 54%; height: 38%; border-radius: 0 0 46% 42%; }
.pkt-avatar--hair-parted .pkt-avatar__hair::after { right: 8%; width: 28%; top: 44%; height: 44%; border-radius: 0 0 40% 40%; }
.pkt-avatar--hair-curly .pkt-avatar__hair { height: 30%; border-radius: 52% 52% 38% 38%; }
.pkt-avatar--hair-curly .pkt-avatar__hair::before,
.pkt-avatar--hair-curly .pkt-avatar__hair::after { top: 56%; width: 26%; height: 44%; border-radius: 50%; }
.pkt-avatar--hair-curly .pkt-avatar__hair::before { left: 8%; }
.pkt-avatar--hair-curly .pkt-avatar__hair::after { right: 8%; }
.pkt-avatar--hair-waves .pkt-avatar__hair::before { left: 7%; width: 34%; top: 48%; height: 42%; border-radius: 48% 42% 38% 42%; }
.pkt-avatar--hair-waves .pkt-avatar__hair::after { right: 5%; width: 38%; top: 44%; height: 46%; border-radius: 46% 46% 40% 44%; }
.pkt-avatar--hair-bald .pkt-avatar__hair { display: none; }
.pkt-avatar__hat { left: 11%; top: 7%; width: 78%; height: 26%; display: none; background: linear-gradient(180deg, color-mix(in srgb, var(--avatar-hat) 86%, white 14%), color-mix(in srgb, var(--avatar-hat) 70%, black 30%)); box-shadow: inset 0 calc(var(--avatar-size) * -0.015) calc(var(--avatar-size) * 0.035) rgba(255,255,255,0.14); }
.pkt-avatar--hat-beanie .pkt-avatar__hat { display: block; border-radius: 48% 48% 22% 22%; }
.pkt-avatar--hat-beanie .pkt-avatar__hat::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 32%; border-radius: 12px; background: color-mix(in srgb, var(--avatar-hat) 88%, black 12%); }
.pkt-avatar--hat-cap .pkt-avatar__hat { display: block; top: 9%; height: 22%; border-radius: 52% 52% 18% 18%; }
.pkt-avatar--hat-cap .pkt-avatar__hat::before { content: ""; position: absolute; left: 42%; top: 68%; width: 42%; height: 24%; border-radius: 0 999px 999px 999px; background: color-mix(in srgb, var(--avatar-hat) 82%, black 18%); transform: skewX(-18deg); }
.pkt-avatar__eye { top: 44%; width: 9%; height: 9%; border-radius: 999px; background: radial-gradient(circle at 42% 42%, rgba(255,255,255,0.85) 0 18%, var(--avatar-eye) 22% 100%); box-shadow: 0 calc(var(--avatar-size) * 0.01) 0 rgba(39,27,19,0.14); }
.pkt-avatar__eye--left { left: 35%; }
.pkt-avatar__eye--right { right: 35%; }
.pkt-avatar__brow { top: 38%; width: 14%; height: 4%; background: color-mix(in srgb, var(--avatar-hair) 85%, black 15%); border-radius: 999px; opacity: .82; }
.pkt-avatar__brow--left { left: 32%; transform: rotate(-8deg); }
.pkt-avatar__brow--right { right: 32%; transform: rotate(8deg); }
.pkt-avatar__nose { left: 47%; top: 49%; width: 6%; height: 12%; border-radius: 999px; background: linear-gradient(180deg, rgba(126, 85, 54, 0.08), rgba(126, 85, 54, 0.26)); }
.pkt-avatar__mouth { left: 39%; top: 62%; width: 22%; height: 8%; border-bottom: 2px solid rgba(103, 66, 43, 0.56); border-radius: 0 0 999px 999px; }
.pkt-avatar__beard { left: 26%; top: 55%; width: 48%; background: linear-gradient(180deg, color-mix(in srgb, var(--avatar-hair) 88%, white 12%), color-mix(in srgb, var(--avatar-hair) 70%, black 30%)); display: none; }
.pkt-avatar--beard-mustache .pkt-avatar__beard { display: block; height: 7%; top: 57%; border-radius: 999px 999px 18px 18px; }
.pkt-avatar--beard-short .pkt-avatar__beard { display: block; height: 19%; top: 56%; border-radius: 16px 16px 40% 40%; }
.pkt-avatar--beard-full .pkt-avatar__beard { display: block; height: 25%; top: 54%; border-radius: 18px 18px 44% 44%; }
.pkt-avatar__glasses { inset: 0; display: none; }
.pkt-avatar__glasses::before,
.pkt-avatar__glasses::after { content: ""; position: absolute; top: 40%; width: 18%; height: 12%; border: 2px solid rgba(53, 53, 53, 0.78); background: rgba(255,255,255,0.18); }
.pkt-avatar__glasses::before { left: 28%; border-radius: 999px; }
.pkt-avatar__glasses::after { right: 28%; border-radius: 999px; box-shadow: calc(var(--avatar-size) * -0.145) 0 0 calc(var(--avatar-size) * -0.132) rgba(53,53,53,0.78); }
.pkt-avatar--glasses-round .pkt-avatar__glasses,
.pkt-avatar--glasses-square .pkt-avatar__glasses { display: block; }
.pkt-avatar--glasses-square .pkt-avatar__glasses::before,
.pkt-avatar--glasses-square .pkt-avatar__glasses::after { border-radius: 6px; }
.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; }
.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; }
@@ -314,6 +311,14 @@ 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); }

View File

@@ -738,6 +738,16 @@ document.addEventListener('DOMContentLoaded', () => {
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;
@@ -769,7 +779,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-avatar-builder]').forEach((form) => {
updateAvatarPreview(form);
form.querySelectorAll('[data-avatar-field]').forEach((field) => {
form.querySelectorAll('[data-avatar-field], [data-avatar-preset-option]').forEach((field) => {
field.addEventListener('change', () => updateAvatarPreview(form));
field.addEventListener('input', () => updateAvatarPreview(form));
});

View File

@@ -30,6 +30,7 @@ CREATE TABLE user_profiles (
lat DECIMAL(10,7) NULL,
lng DECIMAL(10,7) NULL,
location_tracking_preference ENUM('disabled','prompt','enabled') NOT NULL DEFAULT 'prompt',
avatar_preset VARCHAR(32) NOT NULL DEFAULT 'modern_01',
avatar_skin_tone VARCHAR(24) NOT NULL DEFAULT 'warm',
avatar_hair_style VARCHAR(24) NOT NULL DEFAULT 'short',
avatar_hair_color VARCHAR(24) NOT NULL DEFAULT 'brown',

View File

@@ -338,6 +338,7 @@ final class AccountPages
'email' => '',
'contact_phone' => '',
'location_tracking_preference' => 'prompt',
'avatar_preset' => 'modern_01',
'avatar_skin_tone' => 'warm',
'avatar_hair_style' => 'short',
'avatar_hair_color' => 'brown',
@@ -352,7 +353,7 @@ 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_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_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) {
@@ -419,6 +420,7 @@ final class AccountPages
$section = 'profile';
}
$avatarOptions = Avatar::options();
$avatarPresetOptions = Avatar::presetOptions();
return compact(
'flash',
@@ -436,6 +438,7 @@ final class AccountPages
'communityCanApply',
'communityRestrictions',
'avatarOptions',
'avatarPresetOptions',
'section',
'allowedSections'
);

View File

@@ -8,6 +8,7 @@ final class Avatar
public static function defaults(): array
{
return [
'avatar_preset' => 'modern_01',
'avatar_skin_tone' => 'warm',
'avatar_hair_style' => 'short',
'avatar_hair_color' => 'brown',
@@ -24,6 +25,7 @@ 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'],
@@ -90,6 +92,29 @@ 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());
@@ -118,8 +143,26 @@ 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(
'<span class="%s" role="img" aria-label="%s"><img class="pkt-avatar__img" src="%s" alt=""></span>',
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'];
@@ -147,31 +190,70 @@ final class Avatar
);
$label = htmlspecialchars('Avatar von ' . $name, ENT_QUOTES);
$clipId = 'pktAvatarClip' . $instanceCounter;
return sprintf(
'<span class="%s" style="%s" role="img" aria-label="%s">' .
'<span class="pkt-avatar__bg"></span>' .
'<span class="pkt-avatar__shadow"></span>' .
'<span class="pkt-avatar__hoodie"></span>' .
'<span class="pkt-avatar__neck"></span>' .
'<span class="pkt-avatar__ears"></span>' .
'<span class="pkt-avatar__head"></span>' .
'<span class="pkt-avatar__cheek pkt-avatar__cheek--left"></span>' .
'<span class="pkt-avatar__cheek pkt-avatar__cheek--right"></span>' .
'<span class="pkt-avatar__hair"></span>' .
'<span class="pkt-avatar__hat"></span>' .
'<span class="pkt-avatar__eye pkt-avatar__eye--left"></span>' .
'<span class="pkt-avatar__eye pkt-avatar__eye--right"></span>' .
'<span class="pkt-avatar__brow pkt-avatar__brow--left"></span>' .
'<span class="pkt-avatar__brow pkt-avatar__brow--right"></span>' .
'<span class="pkt-avatar__nose"></span>' .
'<span class="pkt-avatar__mouth"></span>' .
'<span class="pkt-avatar__beard"></span>' .
'<span class="pkt-avatar__glasses"></span>' .
'<svg class="pkt-avatar__svg" viewBox="0 0 160 160" aria-hidden="true">' .
'<defs>' .
'<clipPath id="%s"><rect x="0" y="0" width="160" height="160" rx="32" ry="32"></rect></clipPath>' .
'</defs>' .
'<g clip-path="url(#%s)">' .
'<rect class="pkt-avatar__bg" x="0" y="0" width="160" height="160"></rect>' .
'<ellipse class="pkt-avatar__shadow" cx="80" cy="150" rx="62" ry="18"></ellipse>' .
'<path class="pkt-avatar__outfit" d="M22 164c2-30 22-46 58-46s56 16 58 46H22z"></path>' .
'<path class="pkt-avatar__outfit-detail" d="M48 126c8 14 18 20 32 20s24-6 32-20"></path>' .
'<path class="pkt-avatar__neck" d="M68 98h24v20c0 8-6 14-12 14s-12-6-12-14V98z"></path>' .
'<path class="pkt-avatar__ear" d="M48 76c0-7 5-12 11-12v24c-6 0-11-5-11-12z"></path>' .
'<path class="pkt-avatar__ear" d="M112 64c6 0 11 5 11 12s-5 12-11 12V64z"></path>' .
'<path class="pkt-avatar__face" d="M80 28c-24 0-39 17-39 40v18c0 28 17 45 39 45s39-17 39-45V68c0-23-15-40-39-40z"></path>' .
'<path class="pkt-avatar__face-shade" d="M46 90c5 24 20 38 34 38 15 0 31-15 36-40-2 27-18 43-36 43-17 0-32-12-34-41z"></path>' .
'<ellipse class="pkt-avatar__cheek" cx="60" cy="90" rx="9" ry="5"></ellipse>' .
'<ellipse class="pkt-avatar__cheek" cx="100" cy="90" rx="9" ry="5"></ellipse>' .
'<g class="pkt-avatar__hair-set">' .
'<path class="pkt-avatar__hair-base" d="M40 70c0-31 17-48 40-48s40 17 40 48c0 0-2-20-10-30-9-11-22-16-30-16s-21 5-30 16c-8 10-10 30-10 30z"></path>' .
'<path class="pkt-avatar__hair-short" d="M42 69c2-18 12-30 22-36 7-5 18-8 28-5 14 4 24 16 26 41-7-8-14-15-23-20-8-4-16-6-24-5-11 1-20 5-29 25z"></path>' .
'<path class="pkt-avatar__hair-fade" d="M44 70c4-24 18-38 36-38 19 0 33 13 36 38-10-8-17-12-24-14-10-3-21-3-31 0-6 2-12 6-17 14z"></path>' .
'<path class="pkt-avatar__hair-parted" d="M42 69c3-18 13-29 24-35 13-7 30-6 42 6 7 7 10 17 10 29-7-10-12-17-19-20-11-5-21 0-26 7-3-6-9-10-15-10-8 0-12 4-16 8z"></path>' .
'<path class="pkt-avatar__hair-curly" d="M38 72c3-14 8-24 15-31 8-8 16-12 27-12 12 0 22 4 30 13 7 7 11 17 12 30-5-6-9-9-13-10-1 0-4 2-7 1-3-1-4-5-7-5s-5 5-8 4c-3 0-4-4-7-4-4 0-6 6-11 5-4 0-6-5-10-4-5 1-9 5-14 13z"></path>' .
'<path class="pkt-avatar__hair-waves" d="M41 70c2-14 8-24 17-32 8-7 17-10 28-10 17 0 31 12 34 41-5-8-10-12-16-15-4-1-7-2-11-3-3 0-6 6-10 6s-7-7-11-7c-12 0-21 8-31 20z"></path>' .
'</g>' .
'<g class="pkt-avatar__hat-set">' .
'<path class="pkt-avatar__hat-beanie" d="M36 63c2-24 18-40 44-40 24 0 40 14 44 40H36z"></path>' .
'<path class="pkt-avatar__hat-beanie-rib" d="M38 61h84c0 10-6 18-14 18H52c-8 0-14-8-14-18z"></path>' .
'<path class="pkt-avatar__hat-cap" d="M40 63c5-19 18-30 40-30 23 0 35 10 40 30H40z"></path>' .
'<path class="pkt-avatar__hat-cap-brim" d="M80 63c15 0 31 5 44 14-6 3-14 6-26 8-17 2-28 1-39-4 3-7 9-18 21-18z"></path>' .
'</g>' .
'<path class="pkt-avatar__brow pkt-avatar__brow--left" d="M58 67c5-4 12-5 19-2"></path>' .
'<path class="pkt-avatar__brow pkt-avatar__brow--right" d="M83 65c7-3 14-2 19 2"></path>' .
'<ellipse class="pkt-avatar__eye-shell" cx="66" cy="77" rx="8" ry="5"></ellipse>' .
'<ellipse class="pkt-avatar__eye-shell" cx="94" cy="77" rx="8" ry="5"></ellipse>' .
'<circle class="pkt-avatar__eye" cx="66" cy="77" r="3.4"></circle>' .
'<circle class="pkt-avatar__eye" cx="94" cy="77" r="3.4"></circle>' .
'<circle class="pkt-avatar__eye-highlight" cx="65" cy="76" r="1.1"></circle>' .
'<circle class="pkt-avatar__eye-highlight" cx="93" cy="76" r="1.1"></circle>' .
'<path class="pkt-avatar__nose" d="M80 78c-2 5-3 9-3 15 0 3 2 5 5 5"></path>' .
'<path class="pkt-avatar__mouth" d="M69 102c7 5 15 5 22 0"></path>' .
'<g class="pkt-avatar__beard-set">' .
'<path class="pkt-avatar__beard-mustache" d="M64 97c4-4 9-5 16-5 6 0 11 1 16 5-5 1-8 4-10 8-2-3-4-4-6-4s-4 1-6 4c-2-4-5-7-10-8z"></path>' .
'<path class="pkt-avatar__beard-short" d="M60 93c3 4 10 7 20 7s17-3 20-7c0 16-6 27-20 29-14-2-20-13-20-29z"></path>' .
'<path class="pkt-avatar__beard-full" d="M55 90c5 6 13 10 25 10 11 0 20-4 25-10-1 22-9 36-25 38-16-2-24-16-25-38z"></path>' .
'</g>' .
'<g class="pkt-avatar__glasses-set">' .
'<rect class="pkt-avatar__glasses-round-left" x="54" y="70" width="22" height="16" rx="8"></rect>' .
'<rect class="pkt-avatar__glasses-round-right" x="84" y="70" width="22" height="16" rx="8"></rect>' .
'<rect class="pkt-avatar__glasses-square-left" x="54" y="70" width="22" height="16" rx="4"></rect>' .
'<rect class="pkt-avatar__glasses-square-right" x="84" y="70" width="22" height="16" rx="4"></rect>' .
'<path class="pkt-avatar__glasses-bridge" d="M76 78h8"></path>' .
'</g>' .
'</g>' .
'</svg>' .
'</span>',
htmlspecialchars(implode(' ', $classes), ENT_QUOTES),
htmlspecialchars($style, ENT_QUOTES),
$label
$label,
htmlspecialchars($clipId, ENT_QUOTES),
htmlspecialchars($clipId, ENT_QUOTES)
);
}
}

View File

@@ -29,6 +29,7 @@ final class ProfileSettings
}
$avatarColumns = [
'avatar_preset' => "ALTER TABLE user_profiles ADD COLUMN avatar_preset VARCHAR(32) NOT NULL DEFAULT 'modern_01' 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",
@@ -85,6 +86,23 @@ 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