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:
+61
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user