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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user