diff --git a/partials/landing/account/dashboard.php b/partials/landing/account/dashboard.php index a9d0b3a..797ddb1 100755 --- a/partials/landing/account/dashboard.php +++ b/partials/landing/account/dashboard.php @@ -303,6 +303,51 @@ $sectionLinks = [

Wähle eine Lorelei-Variante oder lade dir direkt neue Vorschläge. Die Vorschau aktualisiert sich sofort.

+
+ $meta): ?> + + +
+
+ $meta): ?> +
+
+ + Direkt mit Vorschau auswählbar +
+
+ + + +
+
+ +
@@ -322,77 +367,19 @@ $sectionLinks = [ > - <?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?> + <?= htmlspecialchars((string)$meta['label'], ENT_QUOTES) ?>
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
diff --git a/public/assets/css/app.css b/public/assets/css/app.css index f379a8f..7483b29 100755 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -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); } diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 82c7d60..d82c8dd 100755 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -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); }); }); diff --git a/src/App/Avatar/Lorelei.php b/src/App/Avatar/Lorelei.php index d1dce1d..795cd06 100644 --- a/src/App/Avatar/Lorelei.php +++ b/src/App/Avatar/Lorelei.php @@ -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);