fix(profile): visual frame previews in shop + sidebar avatar sync

Shop items of type 'frame' now render a real avatar-sized preview with
the frame's CSS applied (instead of a generic lucide icon) so buyers
see exactly what they're paying for. Title items get a tag-shaped
preview in their color. The avatar-frames section above the shop also
shows the user's actual avatar inside the frame circles, not 'LS' text.

Sidebar nav-avatar now:
  • renders the uploaded avatar_url instead of always showing initials
    (LS.initPage + new LS.refreshNavAvatar helper)
  • picks up frame CSS on every page via applyCosmetics — previously
    only dashboard.html applied it
  • repaints immediately after picking/deleting an avatar preset
    (avPickPreset / avDelete now call LS.setUser + LS.refreshNavAvatar)

Backend getMyActive resolves avatar_frame to {id, css} for both
gamification frames ('fire', 'crown', ...) and shop-purchased frames
('shop_<id>'), so the client doesn't need a second round-trip to
look up the CSS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-29 15:04:27 +03:00
parent 8e39993bb0
commit 46d373752c
4 changed files with 124 additions and 9 deletions
+61 -2
View File
@@ -432,7 +432,9 @@
background: var(--grad-1);
display: flex; align-items: center; justify-content: center;
font-family: 'Unbounded', sans-serif; font-size: 0.7rem; font-weight: 800; color: #fff;
overflow: visible; box-sizing: border-box;
}
.frame-preview img { display: block; }
.frame-name { font-size: 0.62rem; font-weight: 700; color: var(--text-3); text-align: center; }
.frame-unlock-hint { font-size: 0.56rem; color: var(--text-3); text-align: center; max-width: 80px; }
@@ -484,6 +486,24 @@
.shop-item.owned { border-color: rgba(34,197,94,0.4); background: rgba(34,197,94,0.03); }
.shop-item.disabled:not(.owned) { opacity: 0.5; }
.shop-item-icon { color: var(--violet); margin-bottom: 4px; }
.shop-frame-preview {
width: 56px; height: 56px; border-radius: 50%;
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
display: flex; align-items: center; justify-content: center;
color: #fff; font-family: 'Unbounded', sans-serif;
font-size: 0.78rem; font-weight: 800;
margin-bottom: 8px; flex-shrink: 0;
border: 1.5px solid transparent;
box-sizing: border-box;
}
.shop-frame-preview img { display: block; }
.shop-frame-initials { line-height: 1; }
.shop-title-preview {
font-family: 'Unbounded', sans-serif; font-size: 0.78rem;
font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase;
padding: 8px 14px; margin-bottom: 6px;
border: 1.5px dashed currentColor; border-radius: 99px;
}
.shop-item-name {
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
color: var(--text); line-height: 1.3;
@@ -1507,8 +1527,9 @@
} else {
btn = `<button class="shop-buy-btn ${canBuy ? '' : 'disabled'}" onclick="buyItem(${item.id})" ${canBuy ? '' : 'disabled'}>Купить</button>`;
}
const preview = _renderItemPreview(item);
return `<div class="shop-item ${owned ? 'owned' : ''} ${active ? 'active' : ''} ${!canBuy && !owned ? 'disabled' : ''}">
<div class="shop-item-icon">${LS.icon(item.icon || 'star', 28)}</div>
${preview}
<div class="shop-item-name">${esc(item.name)}</div>
<div class="shop-item-desc">${esc(item.description || '')}</div>
<div class="shop-item-price">${LS.icon('coins', 14)} ${item.price}</div>
@@ -1517,6 +1538,30 @@
}).join('');
}
/* Build the visual hero of a shop item. For frames we render an actual
mini-avatar with the frame CSS applied so buyers see *exactly* what
they're paying for, not a generic lucide icon. */
function _renderItemPreview(item) {
if (item.type === 'frame') {
let css = '';
try { css = (JSON.parse(item.data || '{}').css) || ''; } catch {}
const u = LS.getUser?.() || {};
const inner = u.avatar_url
? `<img src="/avatars/${esc(u.avatar_url)}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`
: `<span class="shop-frame-initials">${esc((u.name || 'LS').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS')}</span>`;
return `<div class="shop-frame-preview" style="${esc(css)}">${inner}</div>`;
}
if (item.type === 'title') {
let titleData = {};
try { titleData = JSON.parse(item.data || '{}'); } catch {}
const color = titleData.color || '#9B5DE5';
const text = titleData.text || item.name;
return `<div class="shop-title-preview" style="color:${esc(color)}">${esc(text)}</div>`;
}
// effects / other — fall back to the lucide icon
return `<div class="shop-item-icon">${LS.icon(item.icon || 'star', 28)}</div>`;
}
async function buyItem(id) {
if (!await LS.confirm('Купить этот предмет?', { title: 'Покупка', confirmText: 'Купить', danger: false })) return;
try {
@@ -1603,11 +1648,15 @@
const data = await LS.getFrames();
if (!data || !data.frames) return;
const grid = document.getElementById('frames-grid');
const u = LS.getUser?.() || {};
const inner = u.avatar_url
? `<img src="/avatars/${esc(u.avatar_url)}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`
: esc((u.name || 'LS').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS');
grid.innerHTML = data.frames.map(f => {
const cls = f.selected ? 'selected' : (!f.unlocked ? 'locked' : '');
const style = f.css ? `style="${f.css}"` : '';
return `<div class="frame-item ${cls}" onclick="selectFrame('${f.id}',${f.unlocked},this)" title="${f.name}${f.unlock ? ' ('+f.unlock+')' : ''}">
<div class="frame-preview" ${style}>LS</div>
<div class="frame-preview" ${style}>${inner}</div>
<div class="frame-name">${esc(f.name)}</div>
${!f.unlocked ? '<div class="frame-unlock-hint">' + lsIcon('lock', 12) + '</div>' : ''}
</div>`;
@@ -1963,6 +2012,11 @@
document.querySelectorAll('.av-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Persist avatar_url to local user cache + repaint sidebar.
const u = LS.getUser?.() || {};
LS.setUser?.({ ...u, avatar_url: filename });
LS.refreshNavAvatar?.();
LS.toast('Аватар обновлён', 'success');
if (LS.sfx) LS.sfx.play('success');
} catch (e) {
@@ -2231,6 +2285,11 @@
document.getElementById('av-del-btn').classList.remove('visible');
document.getElementById('av-modal-status').className = 'av-status-row';
// Reset cached avatar_url + repaint sidebar to initials.
const u = LS.getUser?.() || {};
LS.setUser?.({ ...u, avatar_url: null });
LS.refreshNavAvatar?.();
LS.toast('Аватар удалён', 'success');
avClose();
} catch {