diff --git a/backend/src/controllers/shopController.js b/backend/src/controllers/shopController.js index 3b4ff20..73342b7 100644 --- a/backend/src/controllers/shopController.js +++ b/backend/src/controllers/shopController.js @@ -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_'. 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_') 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); diff --git a/frontend/css/ls.css b/frontend/css/ls.css index 80113e9..4b7814c 100644 --- a/frontend/css/ls.css +++ b/frontend/css/ls.css @@ -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; } diff --git a/frontend/profile.html b/frontend/profile.html index 84da709..7af6a2f 100644 --- a/frontend/profile.html +++ b/frontend/profile.html @@ -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 = ``; } + const preview = _renderItemPreview(item); return `
-
${LS.icon(item.icon || 'star', 28)}
+ ${preview}
${esc(item.name)}
${esc(item.description || '')}
${LS.icon('coins', 14)} ${item.price}
@@ -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 + ? `` + : `${esc((u.name || 'LS').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS')}`; + return `
${inner}
`; + } + 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 `
${esc(text)}
`; + } + // effects / other — fall back to the lucide icon + return `
${LS.icon(item.icon || 'star', 28)}
`; + } + 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 + ? `` + : 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 `
-
LS
+
${inner}
${esc(f.name)}
${!f.unlocked ? '
' + lsIcon('lock', 12) + '
' : ''}
`; @@ -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 { diff --git a/js/api.js b/js/api.js index 76f13e5..e96dd6e 100644 --- a/js/api.js +++ b/js/api.js @@ -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 = ``; + 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');