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
+24 -5
View File
@@ -1,4 +1,24 @@
const db = require('../db/db');
const { AVATAR_FRAMES } = require('./gamification/_shared');
/* Resolve a frame slug to { id, css }. Slug can be either a gamification
frame id (e.g. 'fire', 'crown') or a shop-purchased frame stored as
'shop_<itemId>'. Returns null for 'default' / unknown. */
function resolveFrame(slug) {
if (!slug || slug === 'default') return null;
if (slug.startsWith('shop_')) {
const itemId = Number(slug.slice(5));
if (!Number.isFinite(itemId)) return null;
const item = db.prepare('SELECT data FROM shop_items WHERE id = ? AND type = ?').get(itemId, 'frame');
if (!item) return null;
try {
const data = JSON.parse(item.data || '{}');
return { id: slug, css: data.css || '' };
} catch { return { id: slug, css: '' }; }
}
const f = AVATAR_FRAMES.find(fr => fr.id === slug);
return f ? { id: f.id, css: f.css || '' } : null;
}
/* ═══════════════════════════════════════════════════════════════════════
Shop — Items, Purchases, Coins
@@ -71,11 +91,10 @@ function getMyActive(req, res) {
// Resolve full data for each active item
const result = { frame: null, title: null, effect: null };
// Frame from avatar_frame (gamification frames) — handled separately
// Shop frame override
if (u.avatar_frame && u.avatar_frame !== 'default') {
result.frame = { id: u.avatar_frame };
}
// Frame: resolve either a gamification frame ('fire', 'crown', ...) or
// a shop-purchased one ('shop_<id>') to { id, css } so applyCosmetics
// on the client can render it without an extra round-trip.
result.frame = resolveFrame(u.avatar_frame);
if (u.active_title) {
const item = db.prepare('SELECT data FROM shop_items WHERE id = ?').get(u.active_title);
+1
View File
@@ -494,6 +494,7 @@ body {
font-family: 'Unbounded', sans-serif;
font-size: 0.6rem; font-weight: 800; color: #fff;
flex-shrink: 0;
overflow: hidden;
}
.sb-user-info { flex: 1; min-width: 0; }
+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 {
+38 -2
View File
@@ -707,6 +707,26 @@ function lsModal({ title = '', content = '', size = 'md', actions = [], onClose,
return { close, root: ov, body: bodyEl, setBody, setActions, setError };
}
/* ── renderNavAvatar — paint the sidebar avatar (image or initials) ──
Exported via LS.refreshNavAvatar so pages that update avatar_url
(profile preset picker, upload flow) can re-paint without reload. */
function renderNavAvatar(el, user) {
if (!el) return;
const u = user || getUser();
const url = u?.avatar_url;
if (url) {
el.innerHTML = `<img src="/avatars/${escapeHtml(url)}?t=${Date.now()}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:inherit;display:block">`;
el.style.background = 'transparent';
} else {
const initials = (u?.name || 'LS').split(' ').slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS';
el.textContent = initials;
el.style.background = '';
}
}
function refreshNavAvatar() {
renderNavAvatar(document.getElementById('nav-avatar'));
}
/* ── applyRoleSidebar — reveal teacher/admin sidebar items ───────────── */
function applyRoleSidebar(user) {
if (!user) return;
@@ -722,11 +742,11 @@ function initPage({ requireLogin = true } = {}) {
const isTeacher = user && ['teacher', 'admin'].includes(user.role);
const isAdmin = user?.role === 'admin';
// Nav avatar
// Nav avatar — render uploaded avatar if available, otherwise initials.
const navUser = document.getElementById('nav-user');
const navAvatar = document.getElementById('nav-avatar');
if (navUser) navUser.textContent = user?.name || user?.email || '';
if (navAvatar) navAvatar.textContent = (user?.name || 'LS').split(' ').slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS';
if (navAvatar) renderNavAvatar(navAvatar, user);
// Sidebar collapsed state
if (localStorage.getItem('ls_sb_collapsed') === '1') {
@@ -996,6 +1016,7 @@ window.LS = {
del: (path) => apiFetch(path, { method: 'DELETE' }),
patch: (path, body) => apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }),
applyCosmetics: applyCosmetics,
refreshNavAvatar,
loadFeatures,
clearFeaturesCache,
hideDisabledFeatures,
@@ -1015,6 +1036,21 @@ async function applyCosmetics() {
const c = await getMyActiveCosmetics();
if (!c) return;
// ── Frame: apply CSS to sidebar avatar on every page ──
if (c.frame && c.frame.css) {
const navAv = document.getElementById('nav-avatar');
if (navAv) {
// Strip any previously applied frame styles so swaps don't stack.
if (navAv.dataset.frameApplied) {
navAv.style.cssText = navAv.dataset.frameOrig || '';
} else {
navAv.dataset.frameOrig = navAv.style.cssText || '';
}
navAv.style.cssText += ';' + c.frame.css;
navAv.dataset.frameApplied = '1';
}
}
// ── Title: show under nav username ──
if (c.title && c.title.text) {
const nameEl = document.getElementById('nav-user');