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
+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');