ycxc
All checks were successful
Deploy / deploy (push) Successful in 51s

This commit is contained in:
2026-07-24 21:00:47 +02:00
parent d507ae7bc7
commit 5256dd4080
14 changed files with 471 additions and 19 deletions

View File

@@ -1,5 +1,5 @@
Papa-Kind-Treff Projektstruktur und Pflegehinweise Papa-Kind-Treff Projektstruktur und Pflegehinweise
Stand: 2026-07-22 Stand: 2026-07-24
1. Zweck 1. Zweck
- Plattform für Väter mit Fokus auf lokale Treffen, Community/Forum, Mitgliederbereich und spätere Liveschaltung. - Plattform für Väter mit Fokus auf lokale Treffen, Community/Forum, Mitgliederbereich und spätere Liveschaltung.
@@ -46,7 +46,9 @@ Stand: 2026-07-22
- `src/App/CommunityMigration.php` - `src/App/CommunityMigration.php`
DB-Erweiterungen für Community-Funktionen. DB-Erweiterungen für Community-Funktionen.
- `src/App/ProfileSettings.php` - `src/App/ProfileSettings.php`
Persistente Profileinstellungen für Standortfreigabe. Persistente Profileinstellungen für Standortfreigabe und Avatar-Merkmale.
- `src/App/Avatar.php`
Avatar-Generator, Optionen und HTML-Rendering.
- `src/App/Search.php` - `src/App/Search.php`
Eventsuche inkl. Distanzfilter. Eventsuche inkl. Distanzfilter.
@@ -65,6 +67,7 @@ Stand: 2026-07-22
- Kein eigener Top-Level-Punkt `Termine`; Event-Fokus liegt derzeit auf Startseite und Suche. - Kein eigener Top-Level-Punkt `Termine`; Event-Fokus liegt derzeit auf Startseite und Suche.
- Community mit Kategorien, Boards, Thread-Liste, Thread-Ansicht, Rollen-/Moderationslogik. - 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. - 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 und Kopfform.
- Community-Admin-Bereich für Bewerbungen, Meldungen, Rollen und Migration. - Community-Admin-Bereich für Bewerbungen, Meldungen, Rollen und Migration.
- Impressum, Datenschutz-&-Cookies-Seite und Über-uns vorhanden. - Impressum, Datenschutz-&-Cookies-Seite und Über-uns vorhanden.
- Standortsortierung auf der Startseite für Treffen in der Nähe. - Standortsortierung auf der Startseite für Treffen in der Nähe.

View File

@@ -1,6 +1,6 @@
# Papa-Kind-Treff Chat-Kontext # Papa-Kind-Treff Chat-Kontext
Stand: 2026-07-22 Stand: 2026-07-24
## Kurzbeschreibung ## Kurzbeschreibung
Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind lokale Treffen/Events, ein Community-Forum, Mitgliederprofile mit optionalen Kinderinfos und ein Moderations-/Adminbereich. Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind lokale Treffen/Events, ein Community-Forum, Mitgliederprofile mit optionalen Kinderinfos und ein Moderations-/Adminbereich.
@@ -19,11 +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. - Die Community ist hierarchisch aufgebaut: Kategorien -> Boards -> Threads -> Posts.
- Moderation ist getrennt vom normalen Community-Frontend in einem eigenen Adminbereich. - Moderation ist getrennt vom normalen Community-Frontend in einem eigenen Adminbereich.
- Standortfunktionen sind relevant für lokale Treffen und werden künftig weiter ausgebaut. - Standortfunktionen sind relevant für lokale Treffen und werden künftig weiter ausgebaut.
- Profilbilder werden aktuell über einen integrierten Avatar-Generator statt über Bild-Uploads erzeugt.
## Navigation / Konto ## Navigation / Konto
- Eingeloggte Nutzer sehen rechts ein Profil-Menü statt einzelner `Dashboard`-/`Logout`-Buttons. - Eingeloggte Nutzer sehen rechts ein Profil-Menü statt einzelner `Dashboard`-/`Logout`-Buttons.
- Das Profil-Menü enthält aktuell `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen`, `Abmelden`. - Das Profil-Menü enthält aktuell `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen`, `Abmelden`.
- Der Mitgliederbereich selbst ist als Layout mit linker Bereichsnavigation aufgebaut. - Der Mitgliederbereich selbst ist als Layout mit linker Bereichsnavigation aufgebaut.
- Im Profil kann der Nutzer sein Avatar-Bild aus festen Merkmalen zusammenstellen.
- Debug ist kein Menüpunkt und erscheint nur als Floating-Käfer für `site_admin` bei aktivem Debug-Modus. - Debug ist kein Menüpunkt und erscheint nur als Floating-Käfer für `site_admin` bei aktivem Debug-Modus.
## Community und Rechte ## Community und Rechte

View File

@@ -1,6 +1,6 @@
# Papa-Kind-Treff # Papa-Kind-Treff
Stand: 2026-07-22 Stand: 2026-07-24
Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale Treffen, Community-Austausch und einen geschützten Mitgliederbereich. Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale Treffen, Community-Austausch und einen geschützten Mitgliederbereich.
@@ -17,6 +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 - Impressum, Datenschutz-&-Cookies-Seite und Über-uns
- standortbasierte Sortierung für die neuesten Treffen - standortbasierte Sortierung für die neuesten Treffen
- eigener Einstellungsbereich für Standortfreigabe, Browser-Hinweise und Consent-Verwaltung - eigener Einstellungsbereich für Standortfreigabe, Browser-Hinweise und Consent-Verwaltung
- Avatar-Generator im Profil mit Haut-, Haar-, Augen-, Bart-, Brillen- und Kopfform-Auswahl
- Consent-Manager für Analyse und externe Dienste - Consent-Manager für Analyse und externe Dienste
- Navigation aktuell mit `Home`, `Suche`, `Community` - Navigation aktuell mit `Home`, `Suche`, `Community`
- eingeloggte Nutzer sehen rechts ein Profil-Menü mit Direktlinks zu `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen` - eingeloggte Nutzer sehen rechts ein Profil-Menü mit Direktlinks zu `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen`

View File

@@ -61,6 +61,16 @@ $sectionLinks = [
<p class="muted">Hier pflegst du deine persönlichen Angaben für den Mitgliederbereich.</p> <p class="muted">Hier pflegst du deine persönlichen Angaben für den Mitgliederbereich.</p>
</div> </div>
<div class="account-panel__body"> <div class="account-panel__body">
<div class="profile-avatar-panel">
<div class="profile-avatar-panel__preview">
<?= \App\Avatar::render($profile, $profile['display_name'] ?: 'Papa', 'xl') ?>
</div>
<div class="profile-avatar-panel__content">
<h3>Dein Profilbild</h3>
<p class="muted">Stell dir mit wenigen Klicks ein wiedererkennbares Community-Bild zusammen. Du kannst Haut, Haare, Augen, Bart, Brille und Kopfform direkt anpassen.</p>
<button class="btn" type="button" data-modal-open="modalAvatar">Profilbild gestalten</button>
</div>
</div>
<dl class="account-kv"> <dl class="account-kv">
<div><dt>Name</dt><dd><?= htmlspecialchars(trim($profile['first_name'] . ' ' . $profile['last_name']), ENT_QUOTES) ?></dd></div> <div><dt>Name</dt><dd><?= htmlspecialchars(trim($profile['first_name'] . ' ' . $profile['last_name']), ENT_QUOTES) ?></dd></div>
<div><dt>Anzeigename</dt><dd><?= htmlspecialchars($profile['display_name'], ENT_QUOTES) ?></dd></div> <div><dt>Anzeigename</dt><dd><?= htmlspecialchars($profile['display_name'], ENT_QUOTES) ?></dd></div>
@@ -279,6 +289,103 @@ $sectionLinks = [
</div> </div>
<!-- Modals --> <!-- Modals -->
<div class="modal" id="modalAvatar">
<div class="panel">
<div class="head flex between center-y">
<h3>Profilbild gestalten</h3>
<button class="btn ghost" type="button" data-modal-close>✕</button>
</div>
<form method="post" class="stack gap-12 avatar-builder" data-avatar-builder style="margin-top:12px;">
<input type="hidden" name="action" value="avatar_update">
<div class="avatar-builder__layout">
<div class="avatar-builder__preview">
<div data-avatar-preview>
<?= \App\Avatar::render($profile, $profile['display_name'] ?: 'Papa', 'xxl') ?>
</div>
<p class="muted small">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">
<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>
</div>
<div class="flex gap-12">
<button class="btn ghost" type="button" data-modal-close>Abbrechen</button>
<button class="btn" type="submit">Profilbild speichern</button>
</div>
</form>
</div>
</div>
<div class="modal" id="modalProfile"> <div class="modal" id="modalProfile">
<div class="panel"> <div class="panel">
<div class="head flex between center-y"> <div class="head flex between center-y">

View File

@@ -115,7 +115,7 @@ $threads = $community
?> ?>
<article class="forum-row"> <article class="forum-row">
<div class="forum-row__topic"> <div class="forum-row__topic">
<div class="forum-row__icon" aria-hidden="true">💬</div> <div class="forum-row__avatar"><?= \App\Avatar::render($t, (string)($t['display_name'] ?: 'Mitglied'), 'forum-list') ?></div>
<div> <div>
<h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3> <h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3>
<div class="forum-row__meta"> <div class="forum-row__meta">
@@ -206,7 +206,7 @@ $threads = $community
?> ?>
<article class="forum-row"> <article class="forum-row">
<div class="forum-row__topic"> <div class="forum-row__topic">
<div class="forum-row__icon" aria-hidden="true">💬</div> <div class="forum-row__avatar"><?= \App\Avatar::render($t, (string)($t['display_name'] ?: 'Mitglied'), 'forum-list') ?></div>
<div> <div>
<h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3> <h3><a href="/community_thread?id=<?= (int)$t['id'] ?>"><?= htmlspecialchars((string)$t['title'], ENT_QUOTES) ?></a></h3>
<div class="forum-row__meta"> <div class="forum-row__meta">

View File

@@ -154,7 +154,7 @@ $userVotes = ($access && $userId) ? $access->getUserPostVotes((int)$userId, $pos
<article class="forum-post forum-post--lead"> <article class="forum-post forum-post--lead">
<aside class="forum-post__author"> <aside class="forum-post__author">
<div class="forum-post__avatar" aria-hidden="true"><?= strtoupper(mb_substr($threadAuthor, 0, 1)) ?></div> <div class="forum-post__avatar"><?= \App\Avatar::render($thread, $threadAuthor, 'forum') ?></div>
<strong><?= htmlspecialchars($threadAuthor, ENT_QUOTES) ?></strong> <strong><?= htmlspecialchars($threadAuthor, ENT_QUOTES) ?></strong>
<span><?= htmlspecialchars(trim((string)($threadLevel['icon'] ?? '') . ' ' . (string)($threadLevel['label'] ?? '')), ENT_QUOTES) ?></span> <span><?= htmlspecialchars(trim((string)($threadLevel['icon'] ?? '') . ' ' . (string)($threadLevel['label'] ?? '')), ENT_QUOTES) ?></span>
<span><?= number_format($threadPoints, 1) ?> Punkte</span> <span><?= number_format($threadPoints, 1) ?> Punkte</span>
@@ -203,7 +203,7 @@ $userVotes = ($access && $userId) ? $access->getUserPostVotes((int)$userId, $pos
?> ?>
<article class="forum-post<?= $isHighlighted ? ' forum-post--highlighted' : '' ?>"> <article class="forum-post<?= $isHighlighted ? ' forum-post--highlighted' : '' ?>">
<aside class="forum-post__author"> <aside class="forum-post__author">
<div class="forum-post__avatar" aria-hidden="true"><?= strtoupper(mb_substr($postAuthor, 0, 1)) ?></div> <div class="forum-post__avatar"><?= \App\Avatar::render($p, $postAuthor, 'forum') ?></div>
<strong><?= htmlspecialchars($postAuthor, ENT_QUOTES) ?></strong> <strong><?= htmlspecialchars($postAuthor, ENT_QUOTES) ?></strong>
<?php if ($isHighlighted): ?> <?php if ($isHighlighted): ?>
<span class="forum-highlight-badge">Hilfreich hervorgehoben</span> <span class="forum-highlight-badge">Hilfreich hervorgehoben</span>

View File

@@ -9,14 +9,12 @@ if ($isLoggedIn) {
try { try {
$pdo = $app->pdo(); $pdo = $app->pdo();
if ($pdo) { if ($pdo) {
$stmt = $pdo->prepare('SELECT display_name, first_name FROM user_profiles WHERE user_id = :id LIMIT 1'); $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 FROM user_profiles WHERE user_id = :id LIMIT 1');
$stmt->execute(['id' => (int)$_SESSION['user_id']]); $stmt->execute(['id' => (int)$_SESSION['user_id']]);
$profileRow = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; $profileRow = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
$displayName = trim((string)($profileRow['display_name'] ?? '')) ?: trim((string)($profileRow['first_name'] ?? '')) ?: 'Profil'; $displayName = trim((string)($profileRow['display_name'] ?? '')) ?: trim((string)($profileRow['first_name'] ?? '')) ?: 'Profil';
$firstChar = mb_substr($displayName, 0, 1);
if ($firstChar !== '') {
$profileInitial = mb_strtoupper($firstChar);
}
$communityCfg = dirname(__DIR__, 2) . '/config/community.php'; $communityCfg = dirname(__DIR__, 2) . '/config/community.php';
if (file_exists($communityCfg)) { if (file_exists($communityCfg)) {
@@ -50,7 +48,7 @@ if ($isLoggedIn) {
<?php if ($isLoggedIn): ?> <?php if ($isLoggedIn): ?>
<div class="profile-menu" data-profile-menu> <div class="profile-menu" data-profile-menu>
<button class="profile-menu__trigger" type="button" aria-haspopup="menu" aria-expanded="false" data-profile-menu-trigger> <button class="profile-menu__trigger" type="button" aria-haspopup="menu" aria-expanded="false" data-profile-menu-trigger>
<span class="profile-menu__avatar" aria-hidden="true"><?= htmlspecialchars($profileInitial, ENT_QUOTES) ?></span> <span class="profile-menu__avatar" aria-hidden="true"><?= \App\Avatar::render($profileRow ?? [], $displayName, 'nav') ?></span>
<span class="profile-menu__name"><?= htmlspecialchars($displayName, ENT_QUOTES) ?></span> <span class="profile-menu__name"><?= htmlspecialchars($displayName, ENT_QUOTES) ?></span>
</button> </button>
<div class="profile-menu__dropdown" role="menu"> <div class="profile-menu__dropdown" role="menu">

View File

@@ -52,7 +52,7 @@ body {
.profile-menu { position: relative; } .profile-menu { position: relative; }
.profile-menu__trigger { display: inline-flex; align-items: center; gap: 10px; padding: 6px 10px 6px 6px; border: 1px solid var(--color-border); border-radius: 999px; background: #fff; color: var(--color-text); cursor: pointer; font: inherit; } .profile-menu__trigger { display: inline-flex; align-items: center; gap: 10px; padding: 6px 10px 6px 6px; border: 1px solid var(--color-border); border-radius: 999px; background: #fff; color: var(--color-text); cursor: pointer; font: inherit; }
.profile-menu__trigger:hover { border-color: var(--color-primary); } .profile-menu__trigger:hover { border-color: var(--color-primary); }
.profile-menu__avatar { width: 34px; height: 34px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; background: var(--color-primary); color: var(--color-primary-contrast); font-weight: 700; } .profile-menu__avatar { width: 34px; height: 34px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; }
.profile-menu__name { font-weight: 600; } .profile-menu__name { font-weight: 600; }
.profile-menu__dropdown { position: absolute; top: calc(100% + 10px); right: 0; min-width: 220px; padding: 10px; display: none; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-md); box-shadow: var(--shadow-card); } .profile-menu__dropdown { position: absolute; top: calc(100% + 10px); right: 0; min-width: 220px; padding: 10px; display: none; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-md); box-shadow: var(--shadow-card); }
.profile-menu.is-open .profile-menu__dropdown { display: grid; gap: 4px; } .profile-menu.is-open .profile-menu__dropdown { display: grid; gap: 4px; }
@@ -114,6 +114,66 @@ body {
.step { text-align: left; } .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; } .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; position: relative; display: inline-block; width: var(--avatar-size); height: var(--avatar-size); border-radius: 18px; overflow: hidden; background: linear-gradient(180deg, #fbf6ee, #efe2d2); border: 1px solid rgba(74, 52, 38, 0.12); box-shadow: inset 0 1px 0 rgba(255,255,255,0.65); }
.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__shirt,
.pkt-avatar__neck,
.pkt-avatar__ears,
.pkt-avatar__head,
.pkt-avatar__hair,
.pkt-avatar__eye,
.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 top, rgba(255,255,255,0.82), rgba(255,255,255,0) 48%); }
.pkt-avatar__shirt { left: 16%; bottom: -4%; width: 68%; height: 34%; border-radius: 50% 50% 18% 18%; background: linear-gradient(180deg, #607d94, #44586b); }
.pkt-avatar__neck { left: 43%; bottom: 24%; width: 14%; height: 16%; border-radius: 10px; background: var(--avatar-skin); }
.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: var(--avatar-skin); }
.pkt-avatar__ears::before { left: -6%; }
.pkt-avatar__ears::after { right: -6%; }
.pkt-avatar__head { left: 22%; top: 19%; width: 56%; height: 54%; background: var(--avatar-skin); }
.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__hair { left: 18%; top: 10%; width: 64%; height: 27%; background: var(--avatar-hair); border-radius: 52% 52% 26% 26%; }
.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-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-bald .pkt-avatar__hair { display: none; }
.pkt-avatar__eye { top: 42%; width: 10%; height: 10%; border-radius: 999px; background: var(--avatar-eye); box-shadow: inset 0 0 0 3px rgba(255,255,255,0.65); }
.pkt-avatar__eye--left { left: 35%; }
.pkt-avatar__eye--right { right: 35%; }
.pkt-avatar__nose { left: 47%; top: 48%; width: 6%; height: 12%; border-radius: 999px; background: rgba(126, 85, 54, 0.18); }
.pkt-avatar__mouth { left: 40%; top: 60%; width: 20%; height: 8%; border-bottom: 2px solid rgba(103, 66, 43, 0.55); border-radius: 0 0 999px 999px; }
.pkt-avatar__beard { left: 27%; top: 56%; width: 46%; background: var(--avatar-hair); 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: 18%; top: 56%; border-radius: 14px 14px 38% 38%; }
.pkt-avatar--beard-full .pkt-avatar__beard { display: block; height: 24%; top: 54%; border-radius: 16px 16px 42% 42%; }
.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.75); 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: -24px 0 0 -22px rgba(53,53,53,0.75); }
.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; }
.faq details { border:1px solid var(--color-border); border-radius: var(--radius-sm); padding: 10px 12px; background: #fff; } .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; } .faq summary { cursor:pointer; font-weight: 600; }
.faq p { margin: 8px 0 0 0; color: var(--color-muted); } .faq p { margin: 8px 0 0 0; color: var(--color-muted); }
@@ -225,6 +285,17 @@ body {
.account-kv div { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 14px; } .account-kv div { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 14px; }
.account-kv dt { font-weight: 700; color: var(--color-muted); } .account-kv dt { font-weight: 700; color: var(--color-muted); }
.account-kv dd { margin: 0; } .account-kv dd { margin: 0; }
.profile-avatar-panel { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 22px; align-items: center; margin-bottom: 24px; padding: 20px; background: linear-gradient(135deg, #fffdfa, #f7f0e6); border: 1px solid var(--color-border); border-radius: 18px; }
.profile-avatar-panel__preview { display: flex; justify-content: center; }
.profile-avatar-panel__content h3 { margin: 0 0 8px; }
.profile-avatar-panel__content p { margin: 0 0 14px; }
.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-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; }
.settings-help { display: grid; gap: 14px; } .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 details { border: 1px solid var(--color-border); border-radius: 12px; padding: 12px 14px; background: #fff; }
.settings-help summary { cursor: pointer; font-weight: 700; } .settings-help summary { cursor: pointer; font-weight: 700; }
@@ -233,7 +304,8 @@ body {
.forum-search__row { display: flex; gap: 10px; } .forum-search__row { display: flex; gap: 10px; }
.forum-list__header { display: grid; grid-template-columns: minmax(0, 1fr) 120px 220px; gap: 16px; padding: 14px 24px; background: #f8f4ec; color: var(--color-muted); font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .35px; } .forum-list__header { display: grid; grid-template-columns: minmax(0, 1fr) 120px 220px; gap: 16px; padding: 14px 24px; background: #f8f4ec; color: var(--color-muted); font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .35px; }
.forum-row { display: grid; grid-template-columns: minmax(0, 1fr) 120px 220px; gap: 16px; padding: 20px 24px; border-top: 1px solid var(--color-border); align-items: start; } .forum-row { display: grid; grid-template-columns: minmax(0, 1fr) 120px 220px; gap: 16px; padding: 20px 24px; border-top: 1px solid var(--color-border); align-items: start; }
.forum-row__topic { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 14px; } .forum-row__topic { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 14px; }
.forum-row__avatar { display: flex; align-items: flex-start; justify-content: center; }
.forum-row__icon { width: 40px; height: 40px; border-radius: 12px; display: flex; align-items: center; justify-content: center; background: var(--color-accent-soft); font-size: 18px; } .forum-row__icon { width: 40px; height: 40px; border-radius: 12px; display: flex; align-items: center; justify-content: center; background: var(--color-accent-soft); font-size: 18px; }
.forum-row__topic h3 { margin: 0 0 8px; font-size: 20px; line-height: 1.2; } .forum-row__topic h3 { margin: 0 0 8px; font-size: 20px; line-height: 1.2; }
.forum-row__topic h3 a:hover { color: var(--color-primary); } .forum-row__topic h3 a:hover { color: var(--color-primary); }
@@ -253,7 +325,7 @@ body {
.forum-post + .forum-post { margin-top: 14px; } .forum-post + .forum-post { margin-top: 14px; }
.forum-post--lead { margin-bottom: 24px; } .forum-post--lead { margin-bottom: 24px; }
.forum-post__author { display: grid; gap: 8px; align-content: start; padding: 22px 18px; background: #f8f4ec; border-right: 1px solid var(--color-border); } .forum-post__author { display: grid; gap: 8px; align-content: start; padding: 22px 18px; background: #f8f4ec; border-right: 1px solid var(--color-border); }
.forum-post__avatar { width: 52px; height: 52px; border-radius: 16px; display: flex; align-items: center; justify-content: center; background: var(--color-primary); color: #fff; font-size: 22px; font-weight: 700; } .forum-post__avatar { width: 52px; height: 52px; display: flex; align-items: center; justify-content: center; }
.forum-post__author strong { font-size: 17px; } .forum-post__author strong { font-size: 17px; }
.forum-post__author span { color: var(--color-muted); font-size: 14px; } .forum-post__author span { color: var(--color-muted); font-size: 14px; }
.forum-post__body { padding: 20px 24px; } .forum-post__body { padding: 20px 24px; }
@@ -299,6 +371,9 @@ body {
.forum-row__activity { grid-auto-flow: column; justify-content: start; gap: 10px; align-items: baseline; } .forum-row__activity { grid-auto-flow: column; justify-content: start; gap: 10px; align-items: baseline; }
.forum-post { grid-template-columns: 1fr; } .forum-post { grid-template-columns: 1fr; }
.forum-post__author { border-right: 0; border-bottom: 1px solid var(--color-border); } .forum-post__author { border-right: 0; border-bottom: 1px solid var(--color-border); }
.profile-avatar-panel,
.avatar-builder__layout { grid-template-columns: 1fr; }
.avatar-builder__preview { position: static; }
} }
@media (max-width: 720px){ @media (max-width: 720px){

View File

@@ -726,6 +726,51 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
document.addEventListener('pkt:open-consent', openConsentModal); document.addEventListener('pkt:open-consent', openConsentModal);
const avatarPreviewClassPrefixes = [
'pkt-avatar--head-',
'pkt-avatar--hair-',
'pkt-avatar--beard-',
'pkt-avatar--glasses-',
];
const updateAvatarPreview = (form) => {
const preview = form.querySelector('[data-avatar-preview] .pkt-avatar');
if (!preview) return;
const readValue = (field) => {
const checked = form.querySelector(`[data-avatar-field="${field}"]:checked`);
if (checked) return checked.value;
const input = form.querySelector(`[data-avatar-field="${field}"]`);
return input ? input.value : '';
};
const readColor = (field) => {
const checked = form.querySelector(`[data-avatar-field="${field}"]:checked`);
return checked ? checked.getAttribute('data-avatar-color') || '' : '';
};
avatarPreviewClassPrefixes.forEach((prefix) => {
Array.from(preview.classList)
.filter((className) => className.startsWith(prefix))
.forEach((className) => preview.classList.remove(className));
});
preview.classList.add(`pkt-avatar--head-${readValue('avatar_head_shape')}`);
preview.classList.add(`pkt-avatar--hair-${readValue('avatar_hair_style')}`);
preview.classList.add(`pkt-avatar--beard-${readValue('avatar_beard_style')}`);
preview.classList.add(`pkt-avatar--glasses-${readValue('avatar_glasses_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'));
};
document.querySelectorAll('[data-avatar-builder]').forEach((form) => {
updateAvatarPreview(form);
form.querySelectorAll('[data-avatar-field]').forEach((field) => {
field.addEventListener('change', () => updateAvatarPreview(form));
field.addEventListener('input', () => updateAvatarPreview(form));
});
});
document.querySelectorAll('[data-nav-events]').forEach(link => { document.querySelectorAll('[data-nav-events]').forEach(link => {
link.addEventListener('click', async () => { link.addEventListener('click', async () => {
if (window.location.pathname === '/' && window.location.hash === '#events') { if (window.location.pathname === '/' && window.location.hash === '#events') {

View File

@@ -30,6 +30,13 @@ CREATE TABLE user_profiles (
lat DECIMAL(10,7) NULL, lat DECIMAL(10,7) NULL,
lng DECIMAL(10,7) NULL, lng DECIMAL(10,7) NULL,
location_tracking_preference ENUM('disabled','prompt','enabled') NOT NULL DEFAULT 'prompt', location_tracking_preference ENUM('disabled','prompt','enabled') NOT NULL DEFAULT 'prompt',
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',
avatar_eye_color VARCHAR(24) NOT NULL DEFAULT 'brown',
avatar_beard_style VARCHAR(24) NOT NULL DEFAULT 'none',
avatar_glasses_style VARCHAR(24) NOT NULL DEFAULT 'none',
avatar_head_shape VARCHAR(24) NOT NULL DEFAULT 'round',
contact_phone VARBINARY(512) NULL, contact_phone VARBINARY(512) NULL,
contact_email VARBINARY(512) NULL, contact_email VARBINARY(512) NULL,
profession VARBINARY(512) NULL, profession VARBINARY(512) NULL,

View File

@@ -190,6 +190,11 @@ final class AccountPages
'id' => $userId, 'id' => $userId,
]); ]);
$info = 'Profil gespeichert.'; $info = 'Profil gespeichert.';
} elseif ($action === 'avatar_update') {
if ($profileSettings) {
$profileSettings->updateAvatar($userId, $_POST);
}
$info = 'Profilbild gespeichert.';
} elseif ($action === 'settings_location') { } elseif ($action === 'settings_location') {
$locationPreference = (string)($_POST['location_tracking_preference'] ?? 'prompt'); $locationPreference = (string)($_POST['location_tracking_preference'] ?? 'prompt');
if ($profileSettings) { if ($profileSettings) {
@@ -333,11 +338,18 @@ final class AccountPages
'email' => '', 'email' => '',
'contact_phone' => '', 'contact_phone' => '',
'location_tracking_preference' => 'prompt', 'location_tracking_preference' => 'prompt',
'avatar_skin_tone' => 'warm',
'avatar_hair_style' => 'short',
'avatar_hair_color' => 'brown',
'avatar_eye_color' => 'brown',
'avatar_beard_style' => 'none',
'avatar_glasses_style' => 'none',
'avatar_head_shape' => 'round',
]; ];
if ($profileSettings) { if ($profileSettings) {
$profileSettings->ensureSchema(); $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 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_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 FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1');
$stmt?->execute(['id' => $userId]); $stmt?->execute(['id' => $userId]);
$row = $stmt?->fetch(\PDO::FETCH_ASSOC); $row = $stmt?->fetch(\PDO::FETCH_ASSOC);
if ($row) { if ($row) {
@@ -346,6 +358,7 @@ final class AccountPages
$profile['contact_phone'] = $crypto->decrypt((string)$profile['contact_phone']) ?: ''; $profile['contact_phone'] = $crypto->decrypt((string)$profile['contact_phone']) ?: '';
} }
} }
$profile = array_merge($profile, Avatar::normalize($profile));
$children = []; $children = [];
$stmt = $pdo?->prepare('SELECT id, encrypted_first_name AS first_name, note, gender, birthdate, age_years FROM children WHERE user_id = :id ORDER BY id DESC'); $stmt = $pdo?->prepare('SELECT id, encrypted_first_name AS first_name, note, gender, birthdate, age_years FROM children WHERE user_id = :id ORDER BY id DESC');
@@ -402,6 +415,7 @@ final class AccountPages
if (!in_array($section, $allowedSections, true)) { if (!in_array($section, $allowedSections, true)) {
$section = 'profile'; $section = 'profile';
} }
$avatarOptions = Avatar::options();
return compact( return compact(
'flash', 'flash',
@@ -418,6 +432,7 @@ final class AccountPages
'communityApplication', 'communityApplication',
'communityCanApply', 'communityCanApply',
'communityRestrictions', 'communityRestrictions',
'avatarOptions',
'section', 'section',
'allowedSections' 'allowedSections'
); );

142
src/App/Avatar.php Normal file
View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App;
final class Avatar
{
public static function defaults(): array
{
return [
'avatar_skin_tone' => 'warm',
'avatar_hair_style' => 'short',
'avatar_hair_color' => 'brown',
'avatar_eye_color' => 'brown',
'avatar_beard_style' => 'none',
'avatar_glasses_style' => 'none',
'avatar_head_shape' => 'round',
];
}
public static function options(): array
{
return [
'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'],
],
'avatar_hair_style' => [
'short' => 'Kurz',
'parted' => 'Seitlich',
'curly' => 'Lockig',
'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'],
],
'avatar_eye_color' => [
'brown' => ['label' => 'Braun', 'color' => '#5f4431'],
'hazel' => ['label' => 'Hasel', 'color' => '#7f6845'],
'green' => ['label' => 'Grün', 'color' => '#54704e'],
'blue' => ['label' => 'Blau', 'color' => '#4d6e92'],
],
'avatar_beard_style' => [
'none' => 'Ohne',
'mustache' => 'Schnurrbart',
'short' => 'Kurz',
'full' => 'Vollbart',
],
'avatar_glasses_style' => [
'none' => 'Ohne',
'round' => 'Rund',
'square' => 'Eckig',
],
'avatar_head_shape' => [
'round' => 'Rund',
'oval' => 'Oval',
'square' => 'Eckig',
],
];
}
public static function selectColumns(string $alias = 'p'): string
{
$columns = array_keys(self::defaults());
return implode(', ', array_map(
static fn(string $column): string => sprintf('%s.%s', $alias, $column),
$columns
));
}
public static function normalize(array $input): array
{
$defaults = self::defaults();
$options = self::options();
$normalized = [];
foreach ($defaults as $key => $defaultValue) {
$value = (string)($input[$key] ?? $defaultValue);
if (!array_key_exists($value, $options[$key])) {
$value = $defaultValue;
}
$normalized[$key] = $value;
}
return $normalized;
}
public static function render(array $row, string $name = 'Mitglied', string $size = 'md'): string
{
$avatar = self::normalize($row);
$options = self::options();
$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'];
$classes = [
'pkt-avatar',
'pkt-avatar--' . preg_replace('/[^a-z0-9\-]/', '', strtolower($size)),
'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'],
];
$style = sprintf(
'--avatar-skin:%s;--avatar-hair:%s;--avatar-eye:%s;',
$skin,
$hair,
$eyes
);
$label = htmlspecialchars('Avatar von ' . $name, ENT_QUOTES);
return sprintf(
'<span class="%s" style="%s" role="img" aria-label="%s">' .
'<span class="pkt-avatar__bg"></span>' .
'<span class="pkt-avatar__shirt"></span>' .
'<span class="pkt-avatar__neck"></span>' .
'<span class="pkt-avatar__ears"></span>' .
'<span class="pkt-avatar__head"></span>' .
'<span class="pkt-avatar__hair"></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__nose"></span>' .
'<span class="pkt-avatar__mouth"></span>' .
'<span class="pkt-avatar__beard"></span>' .
'<span class="pkt-avatar__glasses"></span>' .
'</span>',
htmlspecialchars(implode(' ', $classes), ENT_QUOTES),
htmlspecialchars($style, ENT_QUOTES),
$label
);
}
}

View File

@@ -150,9 +150,12 @@ final class Community
$where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : ''; $where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : '';
$avatarSelect = $this->hasColumn('user_profiles', 'avatar_skin_tone')
? ', ' . Avatar::selectColumns('p')
: '';
$sql = "SELECT ft.id, ft.title, ft.body, ft.created_at, ft.updated_at, $sql = "SELECT ft.id, ft.title, ft.body, ft.created_at, ft.updated_at,
u.id as uid, u.created_at as user_created, u.id as uid, u.created_at as user_created,
p.display_name, p.display_name$avatarSelect,
$boardSelect, $boardSelect,
(SELECT COUNT(*) FROM forum_posts fp WHERE fp.thread_id = ft.id) AS answers, (SELECT COUNT(*) FROM forum_posts fp WHERE fp.thread_id = ft.id) AS answers,
COALESCE( COALESCE(
@@ -203,6 +206,9 @@ final class Community
public function getThread(int $id): ?array public function getThread(int $id): ?array
{ {
$select = 'ft.*, p.display_name'; $select = 'ft.*, p.display_name';
if ($this->hasColumn('user_profiles', 'avatar_skin_tone')) {
$select .= ', ' . Avatar::selectColumns('p');
}
$join = ''; $join = '';
if ($this->hasColumn('forum_threads', 'board_id') && $this->hasTable('forum_boards') && $this->hasTable('forum_categories')) { if ($this->hasColumn('forum_threads', 'board_id') && $this->hasTable('forum_boards') && $this->hasTable('forum_categories')) {
$select .= ', fb.slug AS board_slug, fb.title AS board_title, fc.slug AS category_slug, fc.title AS category_title'; $select .= ', fb.slug AS board_slug, fb.title AS board_title, fc.slug AS category_slug, fc.title AS category_title';
@@ -220,6 +226,9 @@ final class Community
public function listPosts(int $threadId): array public function listPosts(int $threadId): array
{ {
$select = 'fp.*, p.display_name'; $select = 'fp.*, p.display_name';
if ($this->hasColumn('user_profiles', 'avatar_skin_tone')) {
$select .= ', ' . Avatar::selectColumns('p');
}
if ($this->hasColumn('forum_posts', 'highlighted_at')) { if ($this->hasColumn('forum_posts', 'highlighted_at')) {
$select .= ', hp.display_name AS highlighted_by_name'; $select .= ', hp.display_name AS highlighted_by_name';
} else { } else {

View File

@@ -28,6 +28,22 @@ final class ProfileSettings
$this->columnCache['user_profiles.location_tracking_preference'] = true; $this->columnCache['user_profiles.location_tracking_preference'] = true;
} }
$avatarColumns = [
'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",
'avatar_eye_color' => "ALTER TABLE user_profiles ADD COLUMN avatar_eye_color VARCHAR(24) NOT NULL DEFAULT 'brown' AFTER avatar_hair_color",
'avatar_beard_style' => "ALTER TABLE user_profiles ADD COLUMN avatar_beard_style VARCHAR(24) NOT NULL DEFAULT 'none' AFTER avatar_eye_color",
'avatar_glasses_style' => "ALTER TABLE user_profiles ADD COLUMN avatar_glasses_style VARCHAR(24) NOT NULL DEFAULT 'none' AFTER avatar_beard_style",
'avatar_head_shape' => "ALTER TABLE user_profiles ADD COLUMN avatar_head_shape VARCHAR(24) NOT NULL DEFAULT 'round' AFTER avatar_glasses_style",
];
foreach ($avatarColumns as $column => $sql) {
if (!$this->hasColumn('user_profiles', $column)) {
$this->pdo->exec($sql);
$this->columnCache['user_profiles.' . $column] = true;
}
}
$this->schemaEnsured = true; $this->schemaEnsured = true;
} }
@@ -59,6 +75,38 @@ final class ProfileSettings
]); ]);
} }
public function updateAvatar(int $userId, array $avatarConfig): void
{
if ($userId <= 0) {
return;
}
$this->ensureSchema();
$avatar = Avatar::normalize($avatarConfig);
$stmt = $this->pdo->prepare(
'UPDATE user_profiles
SET avatar_skin_tone = :skin,
avatar_hair_style = :hairStyle,
avatar_hair_color = :hairColor,
avatar_eye_color = :eyeColor,
avatar_beard_style = :beardStyle,
avatar_glasses_style = :glassesStyle,
avatar_head_shape = :headShape,
updated_at = NOW()
WHERE user_id = :id'
);
$stmt->execute([
'skin' => $avatar['avatar_skin_tone'],
'hairStyle' => $avatar['avatar_hair_style'],
'hairColor' => $avatar['avatar_hair_color'],
'eyeColor' => $avatar['avatar_eye_color'],
'beardStyle' => $avatar['avatar_beard_style'],
'glassesStyle' => $avatar['avatar_glasses_style'],
'headShape' => $avatar['avatar_head_shape'],
'id' => $userId,
]);
}
private function hasColumn(string $table, string $column): bool private function hasColumn(string $table, string $column): bool
{ {
$cacheKey = $table . '.' . $column; $cacheKey = $table . '.' . $column;