This commit is contained in:
@@ -303,6 +303,51 @@ $sectionLinks = [
|
||||
<?= \App\Avatar\Lorelei::render($profile, $profile['display_name'] ?: 'Papa', 'xxl') ?>
|
||||
</div>
|
||||
<p class="muted small">Wähle eine Lorelei-Variante oder lade dir direkt neue Vorschläge. Die Vorschau aktualisiert sich sofort.</p>
|
||||
<div class="avatar-component-nav" role="tablist" aria-label="Avatar-Komponenten">
|
||||
<?php foreach (\App\Avatar\Lorelei::componentDefinitions() as $field => $meta): ?>
|
||||
<button
|
||||
type="button"
|
||||
class="avatar-component-nav__button <?= $field === 'avatar_lorelei_eyes_variant' ? 'is-active' : '' ?>"
|
||||
data-avatar-component-tab="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"
|
||||
aria-selected="<?= $field === 'avatar_lorelei_eyes_variant' ? 'true' : 'false' ?>"
|
||||
>
|
||||
<?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="avatar-component-panels">
|
||||
<?php foreach (\App\Avatar\Lorelei::componentDefinitions() as $field => $meta): ?>
|
||||
<section class="avatar-component-panel <?= $field === 'avatar_lorelei_eyes_variant' ? 'is-active' : '' ?>" data-avatar-component-panel="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>">
|
||||
<div class="avatar-component-panel__head">
|
||||
<strong><?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?></strong>
|
||||
<span class="muted small">Direkt mit Vorschau auswählbar</span>
|
||||
</div>
|
||||
<div class="avatar-component-grid">
|
||||
<?php foreach (\App\Avatar\Lorelei::previewChoices($profile, $field) as $choice): ?>
|
||||
<label class="avatar-component-card">
|
||||
<input
|
||||
type="radio"
|
||||
name="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"
|
||||
value="<?= htmlspecialchars((string)$choice['value'], ENT_QUOTES) ?>"
|
||||
data-avatar-field="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"
|
||||
<?= $choice['checked'] ? 'checked' : '' ?>
|
||||
>
|
||||
<span class="avatar-component-card__thumb">
|
||||
<img
|
||||
src="<?= htmlspecialchars((string)$choice['image'], ENT_QUOTES) ?>"
|
||||
alt="<?= htmlspecialchars((string)$choice['label'], ENT_QUOTES) ?>"
|
||||
data-avatar-option-thumb
|
||||
data-avatar-option-field="<?= htmlspecialchars((string)$field, ENT_QUOTES) ?>"
|
||||
data-avatar-option-value="<?= htmlspecialchars((string)$choice['value'], ENT_QUOTES) ?>"
|
||||
>
|
||||
</span>
|
||||
<span class="avatar-component-card__label"><?= htmlspecialchars((string)$choice['label'], ENT_QUOTES) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avatar-builder__controls">
|
||||
<div class="flex gap-12" style="flex-wrap:wrap;">
|
||||
@@ -322,77 +367,19 @@ $sectionLinks = [
|
||||
<?= $profile['avatar_preset'] === $meta['seed'] || ($profile['avatar_preset'] === '' && $index === 0) ? 'checked' : '' ?>
|
||||
>
|
||||
<span class="avatar-preset-card__thumb">
|
||||
<img src="<?= htmlspecialchars((string)$meta['image'], ENT_QUOTES) ?>" alt="<?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?>">
|
||||
<img
|
||||
src="<?= htmlspecialchars((string)$meta['image'], ENT_QUOTES) ?>"
|
||||
alt="<?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?>"
|
||||
data-avatar-option-thumb
|
||||
data-avatar-option-field="avatar_preset"
|
||||
data-avatar-option-value="<?= htmlspecialchars((string)$meta['seed'], ENT_QUOTES) ?>"
|
||||
>
|
||||
</span>
|
||||
<span class="avatar-preset-card__label"><?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="avatarEyesVariant">Augen</label>
|
||||
<select id="avatarEyesVariant" name="avatar_lorelei_eyes_variant" class="select" data-avatar-field="avatar_lorelei_eyes_variant">
|
||||
<?php foreach ($avatarOptions['avatar_lorelei_eyes_variant'] as $value => $label): ?>
|
||||
<option value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" <?= $profile['avatar_lorelei_eyes_variant'] === $value ? 'selected' : '' ?>><?= htmlspecialchars((string)$label, ENT_QUOTES) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="avatarEyebrowsVariant">Augenbrauen</label>
|
||||
<select id="avatarEyebrowsVariant" name="avatar_lorelei_eyebrows_variant" class="select" data-avatar-field="avatar_lorelei_eyebrows_variant">
|
||||
<?php foreach ($avatarOptions['avatar_lorelei_eyebrows_variant'] as $value => $label): ?>
|
||||
<option value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" <?= $profile['avatar_lorelei_eyebrows_variant'] === $value ? 'selected' : '' ?>><?= htmlspecialchars((string)$label, ENT_QUOTES) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="avatarMouthVariant">Mund</label>
|
||||
<select id="avatarMouthVariant" name="avatar_lorelei_mouth_variant" class="select" data-avatar-field="avatar_lorelei_mouth_variant">
|
||||
<?php foreach ($avatarOptions['avatar_lorelei_mouth_variant'] as $value => $label): ?>
|
||||
<option value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" <?= $profile['avatar_lorelei_mouth_variant'] === $value ? 'selected' : '' ?>><?= htmlspecialchars((string)$label, ENT_QUOTES) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="avatarGlassesVariant">Brille</label>
|
||||
<select id="avatarGlassesVariant" name="avatar_lorelei_glasses_variant" class="select" data-avatar-field="avatar_lorelei_glasses_variant">
|
||||
<?php foreach ($avatarOptions['avatar_lorelei_glasses_variant'] as $value => $label): ?>
|
||||
<option value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" <?= $profile['avatar_lorelei_glasses_variant'] === $value ? 'selected' : '' ?>><?= htmlspecialchars((string)$label, ENT_QUOTES) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="avatarHairVariant">Haare</label>
|
||||
<select id="avatarHairVariant" name="avatar_lorelei_hair_variant" class="select" data-avatar-field="avatar_lorelei_hair_variant">
|
||||
<?php foreach ($avatarOptions['avatar_lorelei_hair_variant'] as $value => $label): ?>
|
||||
<option value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" <?= $profile['avatar_lorelei_hair_variant'] === $value ? 'selected' : '' ?>><?= htmlspecialchars((string)$label, ENT_QUOTES) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="avatarBeardVariant">Bart</label>
|
||||
<select id="avatarBeardVariant" name="avatar_lorelei_beard_variant" class="select" data-avatar-field="avatar_lorelei_beard_variant">
|
||||
<?php foreach ($avatarOptions['avatar_lorelei_beard_variant'] as $value => $label): ?>
|
||||
<option value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" <?= $profile['avatar_lorelei_beard_variant'] === $value ? 'selected' : '' ?>><?= htmlspecialchars((string)$label, ENT_QUOTES) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="avatarEarringsVariant">Ohrringe</label>
|
||||
<select id="avatarEarringsVariant" name="avatar_lorelei_earrings_variant" class="select" data-avatar-field="avatar_lorelei_earrings_variant">
|
||||
<?php foreach ($avatarOptions['avatar_lorelei_earrings_variant'] as $value => $label): ?>
|
||||
<option value="<?= htmlspecialchars((string)$value, ENT_QUOTES) ?>" <?= $profile['avatar_lorelei_earrings_variant'] === $value ? 'selected' : '' ?>><?= htmlspecialchars((string)$label, ENT_QUOTES) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-12">
|
||||
|
||||
@@ -250,6 +250,20 @@ 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-component-nav { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
|
||||
.avatar-component-nav__button { border: 1px solid var(--color-border); background: #fff; color: var(--color-text); border-radius: 999px; padding: 8px 12px; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
|
||||
.avatar-component-nav__button.is-active { background: var(--color-primary); color: var(--color-primary-contrast); border-color: var(--color-primary); }
|
||||
.avatar-component-panels { width: 100%; display: grid; }
|
||||
.avatar-component-panel { display: none; width: 100%; gap: 10px; }
|
||||
.avatar-component-panel.is-active { display: grid; }
|
||||
.avatar-component-panel__head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
|
||||
.avatar-component-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; max-height: 360px; overflow: auto; padding-right: 4px; }
|
||||
.avatar-component-card { position: relative; display: grid; gap: 6px; padding: 8px; background: #fff; border: 1px solid var(--color-border); border-radius: 14px; cursor: pointer; }
|
||||
.avatar-component-card input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.avatar-component-card__thumb { display: block; aspect-ratio: 1; border-radius: 12px; overflow: hidden; background: linear-gradient(180deg, #f7efe6, #f0e4d7); border: 1px solid rgba(74, 52, 38, 0.08); }
|
||||
.avatar-component-card__thumb img { width: 100%; height: 100%; object-fit: cover; object-position: center top; display: block; }
|
||||
.avatar-component-card__label { font-size: 12px; font-weight: 700; color: var(--color-text); text-align: center; }
|
||||
.avatar-component-card:has(input:checked) { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(52,72,90,0.14); }
|
||||
.avatar-preset-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 12px; }
|
||||
.avatar-preset-card { position: relative; 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); }
|
||||
@@ -258,7 +272,7 @@ body {
|
||||
.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); }
|
||||
@media (max-width: 860px){ .avatar-builder__layout { grid-template-columns: 1fr; } .avatar-builder__preview { position: static; } }
|
||||
@media (max-width: 860px){ .avatar-builder__layout { grid-template-columns: 1fr; } .avatar-builder__preview { position: static; } .avatar-component-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.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); }
|
||||
|
||||
@@ -762,11 +762,97 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
image.src = url;
|
||||
};
|
||||
|
||||
const updateAvatarOptionPreviews = (form) => {
|
||||
const checked = form.querySelector('[data-avatar-field="avatar_preset"]:checked');
|
||||
const baseSeed = checked?.value || '';
|
||||
if (!baseSeed) return;
|
||||
|
||||
const currentValues = {};
|
||||
[
|
||||
'avatar_lorelei_eyes_variant',
|
||||
'avatar_lorelei_eyebrows_variant',
|
||||
'avatar_lorelei_mouth_variant',
|
||||
'avatar_lorelei_glasses_variant',
|
||||
'avatar_lorelei_hair_variant',
|
||||
'avatar_lorelei_beard_variant',
|
||||
'avatar_lorelei_earrings_variant',
|
||||
].forEach((field) => {
|
||||
const checkedField = form.querySelector(`[data-avatar-field="${field}"]:checked`);
|
||||
currentValues[field] = checkedField ? checkedField.value : '';
|
||||
});
|
||||
|
||||
const fieldMap = {
|
||||
avatar_lorelei_eyes_variant: 'eyesVariant',
|
||||
avatar_lorelei_eyebrows_variant: 'eyebrowsVariant',
|
||||
avatar_lorelei_mouth_variant: 'mouthVariant',
|
||||
avatar_lorelei_glasses_variant: 'glassesVariant',
|
||||
avatar_lorelei_hair_variant: 'hairVariant',
|
||||
avatar_lorelei_beard_variant: 'beardVariant',
|
||||
avatar_lorelei_earrings_variant: 'earringsVariant',
|
||||
};
|
||||
|
||||
form.querySelectorAll('[data-avatar-option-thumb]').forEach((image) => {
|
||||
const field = image.getAttribute('data-avatar-option-field') || '';
|
||||
const value = image.getAttribute('data-avatar-option-value') || '';
|
||||
const seed = field === 'avatar_preset' ? value : baseSeed;
|
||||
if (!seed) return;
|
||||
|
||||
const params = new URLSearchParams({ seed });
|
||||
Object.entries(currentValues).forEach(([currentField, currentValue]) => {
|
||||
const param = fieldMap[currentField];
|
||||
if (!param) return;
|
||||
const appliedValue = currentField === field ? value : currentValue;
|
||||
if (appliedValue) params.set(param, appliedValue);
|
||||
});
|
||||
|
||||
image.src = `/api/avatar?${params.toString()}`;
|
||||
});
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-avatar-builder]').forEach((form) => {
|
||||
updateAvatarPreview(form);
|
||||
updateAvatarOptionPreviews(form);
|
||||
form.querySelectorAll('[data-avatar-field]').forEach((field) => {
|
||||
field.addEventListener('change', () => updateAvatarPreview(form));
|
||||
field.addEventListener('input', () => updateAvatarPreview(form));
|
||||
field.addEventListener('change', () => {
|
||||
updateAvatarPreview(form);
|
||||
updateAvatarOptionPreviews(form);
|
||||
});
|
||||
field.addEventListener('input', () => {
|
||||
updateAvatarPreview(form);
|
||||
updateAvatarOptionPreviews(form);
|
||||
});
|
||||
});
|
||||
|
||||
const setActiveComponentPanel = (field) => {
|
||||
form.querySelectorAll('[data-avatar-component-tab]').forEach((button) => {
|
||||
const active = button.getAttribute('data-avatar-component-tab') === field;
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
form.querySelectorAll('[data-avatar-component-panel]').forEach((panel) => {
|
||||
panel.classList.toggle('is-active', panel.getAttribute('data-avatar-component-panel') === field);
|
||||
});
|
||||
};
|
||||
|
||||
form.querySelectorAll('[data-avatar-component-tab]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const field = button.getAttribute('data-avatar-component-tab');
|
||||
if (field) setActiveComponentPanel(field);
|
||||
});
|
||||
});
|
||||
|
||||
[
|
||||
'avatar_lorelei_eyes_variant',
|
||||
'avatar_lorelei_eyebrows_variant',
|
||||
'avatar_lorelei_mouth_variant',
|
||||
'avatar_lorelei_glasses_variant',
|
||||
'avatar_lorelei_hair_variant',
|
||||
'avatar_lorelei_beard_variant',
|
||||
'avatar_lorelei_earrings_variant',
|
||||
].forEach((field) => {
|
||||
form.querySelectorAll(`[data-avatar-field="${field}"]`).forEach((input) => {
|
||||
input.addEventListener('change', () => setActiveComponentPanel(field));
|
||||
});
|
||||
});
|
||||
|
||||
const avatarPrefixes = [
|
||||
@@ -812,6 +898,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (label) label.textContent = index === 0 ? 'Neue Auswahl' : `Variante ${index + 1}`;
|
||||
});
|
||||
updateAvatarPreview(form);
|
||||
updateAvatarOptionPreviews(form);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,19 @@ final class Lorelei
|
||||
];
|
||||
}
|
||||
|
||||
public static function componentDefinitions(): array
|
||||
{
|
||||
return [
|
||||
'avatar_lorelei_eyes_variant' => ['label' => 'Augen', 'auto' => false],
|
||||
'avatar_lorelei_eyebrows_variant' => ['label' => 'Augenbrauen', 'auto' => false],
|
||||
'avatar_lorelei_mouth_variant' => ['label' => 'Mund', 'auto' => false],
|
||||
'avatar_lorelei_glasses_variant' => ['label' => 'Brille', 'auto' => true],
|
||||
'avatar_lorelei_hair_variant' => ['label' => 'Haare', 'auto' => true],
|
||||
'avatar_lorelei_beard_variant' => ['label' => 'Bart', 'auto' => true],
|
||||
'avatar_lorelei_earrings_variant' => ['label' => 'Ohrringe', 'auto' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public static function selectColumns(string $alias = 'p'): string
|
||||
{
|
||||
$columns = array_keys(self::defaults());
|
||||
@@ -162,6 +175,31 @@ final class Lorelei
|
||||
return '/api/avatar?' . http_build_query(self::apiQuery($row));
|
||||
}
|
||||
|
||||
public static function previewChoices(array $row, string $field): array
|
||||
{
|
||||
$definitions = self::componentDefinitions();
|
||||
$options = self::options();
|
||||
$avatar = self::normalize($row);
|
||||
|
||||
if (!isset($definitions[$field], $options[$field])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$choices = [];
|
||||
foreach ($options[$field] as $value => $label) {
|
||||
$variantRow = $avatar;
|
||||
$variantRow[$field] = (string) $value;
|
||||
$choices[] = [
|
||||
'value' => (string) $value,
|
||||
'label' => (string) $label,
|
||||
'image' => self::buildProxyUrl($variantRow),
|
||||
'checked' => $avatar[$field] === (string) $value,
|
||||
];
|
||||
}
|
||||
|
||||
return $choices;
|
||||
}
|
||||
|
||||
public static function render(array $row, string $name = 'Mitglied', string $size = 'md'): string
|
||||
{
|
||||
$avatar = self::normalize($row);
|
||||
|
||||
Reference in New Issue
Block a user