feat(shop): animated backgrounds — system-wide cosmetic + picker
A new cosmetic family: a fixed-position overlay painted behind every
page of the app, switchable from the profile shop. 4 free presets + 6
paid (250-1200 coins) so the new economy has another sink. Every
animation respects prefers-reduced-motion and falls back to its static
gradient.
Catalogue (migration 035):
free: none, gradient-soft, dots, dark
paid: gradient-flow, grid, bubbles, stars (mid)
aurora, nebula (premium)
Backend:
• migration 035 adds users.active_background + rebuilds shop_items
CHECK to include 'background' (standard SQLite 'new + copy + swap')
and seeds 10 items
• shopController.getMyActive returns { background: { slug } } and
activateItem handles type='background' (stores bare slug in
active_background) + skips the user_purchases check for price=0
so free presets work for everyone without per-user rows
• routes/shop validate schema lets 'background' through
Frontend:
• api.js applyCosmetics injects <div id='ls-bg-fx'> at body start
and toggles class to bg-<slug>. Cleared backgrounds remove the
element so dark→light transitions don't leave artifacts.
• ls.css gains a self-contained 'ANIMATED BACKGROUNDS' block:
keyframes per animated slug (ls-bg-flow, ls-bg-grid-scan,
ls-bg-bubble-rise, ls-bg-stars-twinkle, ls-bg-aurora-spin,
ls-bg-nebula-pan) wrapped in a prefers-reduced-motion kill-switch.
Same .bg-<slug> classes are reused for the .bg-preview swatches.
• profile.html shop:
- new 'Фоны' filter button between Рамки and Титулы
- _renderItemPreview type='background' draws a real 56-aspect swatch
(same CSS as the page bg — what you see is what you apply)
- _isItemActive matches by slug for background type
- free items (price===0) treated as auto-owned in render so users
can apply them without a fake 'purchase' step
Verified: getMyActive returns { background: { slug: 'nebula' } } after
flipping users.active_background; activate path updates the row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -86,10 +86,10 @@ function getCoins(req, res) {
|
||||
|
||||
/* GET /api/shop/my-active — return user's active cosmetics */
|
||||
function getMyActive(req, res) {
|
||||
const u = db.prepare('SELECT avatar_frame, active_title, active_effect FROM users WHERE id = ?').get(req.user.id);
|
||||
const u = db.prepare('SELECT avatar_frame, active_title, active_effect, active_background FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!u) return res.json({});
|
||||
// Resolve full data for each active item
|
||||
const result = { frame: null, title: null, effect: null };
|
||||
const result = { frame: null, title: null, effect: null, background: null };
|
||||
|
||||
// Frame: resolve either a gamification frame ('fire', 'crown', ...) or
|
||||
// a shop-purchased one ('shop_<id>') to { id, css } so applyCosmetics
|
||||
@@ -104,6 +104,11 @@ function getMyActive(req, res) {
|
||||
const item = db.prepare('SELECT data FROM shop_items WHERE id = ?').get(u.active_effect);
|
||||
if (item) try { result.effect = JSON.parse(item.data); } catch {}
|
||||
}
|
||||
// Background: stored as the bare slug (e.g. 'aurora') in users.active_background.
|
||||
// 'none' / null → no extra rendering on the client.
|
||||
if (u.active_background && u.active_background !== 'none') {
|
||||
result.background = { slug: u.active_background };
|
||||
}
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
@@ -115,24 +120,33 @@ function activateItem(req, res) {
|
||||
// Deactivate: pass itemId = null and type
|
||||
if (!itemId) {
|
||||
const { type } = req.body;
|
||||
if (type === 'title') db.prepare('UPDATE users SET active_title = NULL WHERE id = ?').run(userId);
|
||||
if (type === 'effect') db.prepare('UPDATE users SET active_effect = NULL WHERE id = ?').run(userId);
|
||||
if (type === 'frame') db.prepare("UPDATE users SET avatar_frame = 'default' WHERE id = ?").run(userId);
|
||||
if (type === 'title') db.prepare('UPDATE users SET active_title = NULL WHERE id = ?').run(userId);
|
||||
if (type === 'effect') db.prepare('UPDATE users SET active_effect = NULL WHERE id = ?').run(userId);
|
||||
if (type === 'frame') db.prepare("UPDATE users SET avatar_frame = 'default' WHERE id = ?").run(userId);
|
||||
if (type === 'background') db.prepare('UPDATE users SET active_background = NULL WHERE id = ?').run(userId);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
const item = db.prepare('SELECT * FROM shop_items WHERE id = ?').get(itemId);
|
||||
if (!item) return res.status(404).json({ error: 'Предмет не найден' });
|
||||
|
||||
const owned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId);
|
||||
if (!owned) return res.status(403).json({ error: 'Предмет не куплен' });
|
||||
// Free items (price=0) skip the ownership check — backgrounds &
|
||||
// future freebies are available to everyone without a purchase row.
|
||||
if (item.price > 0) {
|
||||
const owned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId);
|
||||
if (!owned) return res.status(403).json({ error: 'Предмет не куплен' });
|
||||
}
|
||||
|
||||
let data;
|
||||
try { data = JSON.parse(item.data); } catch { data = {}; }
|
||||
|
||||
if (item.type === 'frame') db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?').run('shop_' + itemId, userId);
|
||||
if (item.type === 'title') db.prepare('UPDATE users SET active_title = ? WHERE id = ?').run(itemId, userId);
|
||||
if (item.type === 'effect') db.prepare('UPDATE users SET active_effect = ? WHERE id = ?').run(itemId, userId);
|
||||
if (item.type === 'frame') db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?').run('shop_' + itemId, userId);
|
||||
if (item.type === 'title') db.prepare('UPDATE users SET active_title = ? WHERE id = ?').run(itemId, userId);
|
||||
if (item.type === 'effect') db.prepare('UPDATE users SET active_effect = ? WHERE id = ?').run(itemId, userId);
|
||||
if (item.type === 'background') {
|
||||
const slug = (data && data.slug) || 'none';
|
||||
db.prepare('UPDATE users SET active_background = ? WHERE id = ?').run(slug, userId);
|
||||
}
|
||||
|
||||
res.json({ ok: true, type: item.type, data });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 035: Animated backgrounds — a new cosmetic family
|
||||
--
|
||||
-- Adds an animated background layer that paints behind every page
|
||||
-- of the app. Switchable from the profile page (Phase 6 UI). 4
|
||||
-- backgrounds are free (price=0, available to everyone — the picker
|
||||
-- treats price-0 items as owned without a user_purchases row), 6 are
|
||||
-- paid in the 200-1200 coin range to give the new economy something
|
||||
-- to reach for.
|
||||
--
|
||||
-- Storage:
|
||||
-- • new column users.active_background — slug of the chosen bg
|
||||
-- (NULL = system default; client renders nothing extra)
|
||||
-- • new shop_items rows with type='background' + data.slug
|
||||
-- • the existing CHECK (type IN ('frame','theme','title','effect'))
|
||||
-- on shop_items has to be rebuilt to include 'background' — done
|
||||
-- here via the standard SQLite "create new + copy + swap" dance.
|
||||
--
|
||||
-- Rendering (frontend):
|
||||
-- The applyCosmetics call injects <div id="ls-bg-fx"> at body start
|
||||
-- and sets its class to bg-<slug>. CSS in ls.css (added in the same
|
||||
-- commit) drives the per-slug animation. Every animation respects
|
||||
-- prefers-reduced-motion — falls back to the static gradient.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE users ADD COLUMN active_background TEXT;
|
||||
|
||||
-- ── Rebuild shop_items with 'background' added to the type check ──
|
||||
CREATE TABLE shop_items_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'frame'
|
||||
CHECK (type IN ('frame','theme','title','effect','background')),
|
||||
category TEXT NOT NULL DEFAULT 'cosmetic',
|
||||
price INTEGER NOT NULL DEFAULT 100,
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
icon TEXT NOT NULL DEFAULT 'star',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT INTO shop_items_new
|
||||
(id, name, description, type, category, price, data, icon, is_active, created_at)
|
||||
SELECT
|
||||
id, name, description, type, category, price, data, icon, is_active, created_at
|
||||
FROM shop_items;
|
||||
|
||||
DROP TABLE shop_items;
|
||||
ALTER TABLE shop_items_new RENAME TO shop_items;
|
||||
|
||||
-- ── Free backgrounds (price=0; auto-owned) ─────────────────────
|
||||
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
|
||||
SELECT 'Без фона', 'Системный фон без эффектов', 'background', 'cosmetic', 0,
|
||||
'{"slug":"none"}', 'circle-off', 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Без фона' AND type='background');
|
||||
|
||||
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
|
||||
SELECT 'Мягкий градиент', 'Спокойный переход двух пастельных тонов', 'background', 'cosmetic', 0,
|
||||
'{"slug":"gradient-soft"}', 'palette', 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Мягкий градиент' AND type='background');
|
||||
|
||||
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
|
||||
SELECT 'Точки', 'Тонкая сетка точек на светлом фоне', 'background', 'cosmetic', 0,
|
||||
'{"slug":"dots"}', 'more-horizontal', 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Точки' AND type='background');
|
||||
|
||||
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
|
||||
SELECT 'Тёмный фон', 'Глубокий синий фон без анимаций', 'background', 'cosmetic', 0,
|
||||
'{"slug":"dark"}', 'moon', 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Тёмный фон' AND type='background');
|
||||
|
||||
-- ── Paid backgrounds ─────────────────────────────────────────────
|
||||
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
|
||||
SELECT 'Плавающий градиент', 'Медленно перетекающие фиолетово-бирюзовые волны', 'background', 'cosmetic', 250,
|
||||
'{"slug":"gradient-flow"}', 'waves', 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Плавающий градиент' AND type='background');
|
||||
|
||||
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
|
||||
SELECT 'Сетка', 'Голубая инженерная сетка с подсветкой', 'background', 'cosmetic', 350,
|
||||
'{"slug":"grid"}', 'grid-3x3', 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Сетка' AND type='background');
|
||||
|
||||
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
|
||||
SELECT 'Пузырьки', 'Лёгкие восходящие пузырьки', 'background', 'cosmetic', 450,
|
||||
'{"slug":"bubbles"}', 'droplets', 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Пузырьки' AND type='background');
|
||||
|
||||
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
|
||||
SELECT 'Звёзды', 'Мерцающее звёздное небо', 'background', 'cosmetic', 600,
|
||||
'{"slug":"stars"}', 'sparkles', 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Звёзды' AND type='background');
|
||||
|
||||
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
|
||||
SELECT 'Северное сияние', 'Плавающий conic-gradient в холодных тонах', 'background', 'cosmetic', 900,
|
||||
'{"slug":"aurora"}', 'wand-sparkles', 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Северное сияние' AND type='background');
|
||||
|
||||
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
|
||||
SELECT 'Туманность', 'Космическая туманность с глубиной', 'background', 'cosmetic', 1200,
|
||||
'{"slug":"nebula"}', 'galaxy', 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Туманность' AND type='background');
|
||||
@@ -18,10 +18,10 @@ function shopGate(req, res, next) {
|
||||
}
|
||||
|
||||
const purchaseLimiter = rateLimit({ windowMs: 60_000, max: 10, message: 'Слишком много покупок, подождите минуту' });
|
||||
const activateSchema = { body: { type: { type: 'string', oneOf: ['frame', 'title', 'effect'] } } };
|
||||
const activateSchema = { body: { type: { type: 'string', oneOf: ['frame', 'title', 'effect', 'background'] } } };
|
||||
const adminItemSchema = { body: {
|
||||
name: { type: 'string', required: true, minLen: 1, maxLen: 200 },
|
||||
type: { type: 'string', required: true, oneOf: ['frame', 'title', 'effect'] },
|
||||
type: { type: 'string', required: true, oneOf: ['frame', 'title', 'effect', 'background'] },
|
||||
price: { type: 'number', required: true, min: 0 },
|
||||
}};
|
||||
const awardCoinsSchema = { body: {
|
||||
|
||||
@@ -1194,3 +1194,202 @@ body.no-gamification [data-gamified] { display: none !important; }
|
||||
.rounded-md { border-radius: var(--r-md); }
|
||||
.rounded-lg { border-radius: var(--r-lg); }
|
||||
.rounded-full { border-radius: var(--r-pill); }
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
ANIMATED BACKGROUNDS (Phase 6)
|
||||
|
||||
Painted by a single fixed div appended to <body> by api.js
|
||||
applyCosmetics: <div id="ls-bg-fx" class="bg-<slug>"></div>.
|
||||
The container is always position:fixed inset:0 z-index:-1, never
|
||||
intercepts pointer events, and sits BEHIND every page chrome.
|
||||
|
||||
Each slug has a static fallback first (no animation), then opts
|
||||
into motion outside the prefers-reduced-motion overlay below.
|
||||
Picker preview swatches reuse `.bg-preview.<slug>` with the same
|
||||
visuals at smaller scale.
|
||||
══════════════════════════════════════════ */
|
||||
#ls-bg-fx {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
will-change: opacity, transform, background-position;
|
||||
transition: opacity .4s ease;
|
||||
}
|
||||
.bg-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
/* ── 1. none (no overlay) ─────────────────────────────────────── */
|
||||
.bg-none, .bg-preview.bg-none { background: transparent; }
|
||||
|
||||
/* ── 2. gradient-soft (free, static) ──────────────────────────── */
|
||||
.bg-gradient-soft,
|
||||
.bg-preview.bg-gradient-soft {
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #fce7f3 100%);
|
||||
}
|
||||
|
||||
/* ── 3. dots (free, static) ───────────────────────────────────── */
|
||||
.bg-dots,
|
||||
.bg-preview.bg-dots {
|
||||
background-color: #fafbff;
|
||||
background-image: radial-gradient(rgba(99,102,241,0.18) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
}
|
||||
|
||||
/* ── 4. dark (free, static) ───────────────────────────────────── */
|
||||
.bg-dark,
|
||||
.bg-preview.bg-dark {
|
||||
background: radial-gradient(ellipse at top, #1e1b4b 0%, #0f172a 100%);
|
||||
}
|
||||
|
||||
/* ── 5. gradient-flow (paid, animated) ────────────────────────── */
|
||||
@keyframes ls-bg-flow {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
.bg-gradient-flow,
|
||||
.bg-preview.bg-gradient-flow {
|
||||
background: linear-gradient(120deg, #9B5DE5, #06D6E0, #FFD166, #9B5DE5);
|
||||
background-size: 300% 300%;
|
||||
animation: ls-bg-flow 24s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── 6. grid (paid, animated) ─────────────────────────────────── */
|
||||
@keyframes ls-bg-grid-scan {
|
||||
0% { background-position: 0 0, 0 0; }
|
||||
100% { background-position: 0 60px, 60px 0; }
|
||||
}
|
||||
.bg-grid,
|
||||
.bg-preview.bg-grid {
|
||||
background-color: #0f172a;
|
||||
background-image:
|
||||
linear-gradient(rgba(6,214,224,0.18) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(6,214,224,0.18) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
animation: ls-bg-grid-scan 18s linear infinite;
|
||||
}
|
||||
|
||||
/* ── 7. bubbles (paid, animated) ──────────────────────────────── */
|
||||
@keyframes ls-bg-bubble-rise {
|
||||
0% { transform: translateY(0) scale(0.9); opacity: 0.55; }
|
||||
50% { transform: translateY(-50vh) scale(1.05); opacity: 0.85; }
|
||||
100% { transform: translateY(-100vh) scale(0.9); opacity: 0; }
|
||||
}
|
||||
.bg-bubbles,
|
||||
.bg-preview.bg-bubbles {
|
||||
background: linear-gradient(180deg, #cbeaff 0%, #e9f5ff 100%);
|
||||
position: relative;
|
||||
}
|
||||
.bg-bubbles::before,
|
||||
.bg-bubbles::after,
|
||||
.bg-preview.bg-bubbles::before,
|
||||
.bg-preview.bg-bubbles::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 12% 80%, rgba(255,255,255,0.7) 6px, transparent 7px),
|
||||
radial-gradient(circle at 38% 90%, rgba(255,255,255,0.5) 9px, transparent 10px),
|
||||
radial-gradient(circle at 65% 95%, rgba(255,255,255,0.6) 5px, transparent 6px),
|
||||
radial-gradient(circle at 85% 75%, rgba(255,255,255,0.55) 11px, transparent 12px);
|
||||
animation: ls-bg-bubble-rise 14s linear infinite;
|
||||
}
|
||||
.bg-bubbles::after,
|
||||
.bg-preview.bg-bubbles::after {
|
||||
background-image:
|
||||
radial-gradient(circle at 22% 70%, rgba(255,255,255,0.5) 8px, transparent 9px),
|
||||
radial-gradient(circle at 50% 88%, rgba(255,255,255,0.6) 4px, transparent 5px),
|
||||
radial-gradient(circle at 78% 60%, rgba(255,255,255,0.6) 7px, transparent 8px);
|
||||
animation-duration: 19s;
|
||||
animation-delay: -6s;
|
||||
}
|
||||
|
||||
/* ── 8. stars (paid, animated) ────────────────────────────────── */
|
||||
@keyframes ls-bg-stars-twinkle {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
.bg-stars,
|
||||
.bg-preview.bg-stars {
|
||||
background-color: #0a0e27;
|
||||
background-image:
|
||||
radial-gradient(2px 2px at 20% 30%, #ffffff, transparent),
|
||||
radial-gradient(1px 1px at 40% 70%, #ffffff, transparent),
|
||||
radial-gradient(2px 2px at 60% 20%, #ffffff, transparent),
|
||||
radial-gradient(1px 1px at 80% 50%, #ffffff, transparent),
|
||||
radial-gradient(2px 2px at 90% 85%, #ffffff, transparent),
|
||||
radial-gradient(1px 1px at 10% 90%, #ffffff, transparent),
|
||||
radial-gradient(2px 2px at 50% 10%, #ffffff, transparent),
|
||||
radial-gradient(1px 1px at 30% 50%, #ffffff, transparent);
|
||||
background-size: 200px 200px;
|
||||
animation: ls-bg-stars-twinkle 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── 9. aurora (premium, animated) ────────────────────────────── */
|
||||
@keyframes ls-bg-aurora-spin {
|
||||
0% { transform: rotate(0deg) scale(1.2); }
|
||||
50% { transform: rotate(180deg) scale(1.4); }
|
||||
100% { transform: rotate(360deg) scale(1.2); }
|
||||
}
|
||||
.bg-aurora,
|
||||
.bg-preview.bg-aurora {
|
||||
background: #050b1a;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bg-aurora::before,
|
||||
.bg-preview.bg-aurora::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -40%;
|
||||
background: conic-gradient(
|
||||
from 0deg at 50% 50%,
|
||||
transparent 0%,
|
||||
rgba(6,214,224,0.45) 12%,
|
||||
rgba(155,93,229,0.55) 24%,
|
||||
transparent 36%,
|
||||
rgba(6,214,160,0.35) 60%,
|
||||
transparent 78%,
|
||||
rgba(255,107,53,0.30) 92%,
|
||||
transparent 100%
|
||||
);
|
||||
filter: blur(46px);
|
||||
animation: ls-bg-aurora-spin 40s linear infinite;
|
||||
}
|
||||
|
||||
/* ── 10. nebula (premium, animated) ───────────────────────────── */
|
||||
@keyframes ls-bg-nebula-pan {
|
||||
0% { background-position: 0% 0%, 100% 100%; }
|
||||
50% { background-position: 50% 50%, 50% 50%; }
|
||||
100% { background-position: 0% 0%, 100% 100%; }
|
||||
}
|
||||
.bg-nebula,
|
||||
.bg-preview.bg-nebula {
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 30%, rgba(155,93,229,0.6), transparent 55%),
|
||||
radial-gradient(ellipse at 70% 70%, rgba(6,214,224,0.5), transparent 55%),
|
||||
radial-gradient(circle at 50% 50%, rgba(255,107,53,0.25), transparent 60%),
|
||||
#050214;
|
||||
background-size: 150% 150%, 150% 150%, 100% 100%, 100% 100%;
|
||||
animation: ls-bg-nebula-pan 30s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Reduced-motion: kill all bg animations, keep static palette ─ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#ls-bg-fx,
|
||||
#ls-bg-fx::before,
|
||||
#ls-bg-fx::after,
|
||||
.bg-preview,
|
||||
.bg-preview::before,
|
||||
.bg-preview::after {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
+28
-1
@@ -537,6 +537,17 @@
|
||||
padding: 8px 14px; margin-bottom: 6px;
|
||||
border: 1.5px dashed currentColor; border-radius: 99px;
|
||||
}
|
||||
/* Background preview swatch in shop items — uses the same .bg-<slug>
|
||||
classes as the full-page background so what you see is what you
|
||||
get. Compact size to match other previews. */
|
||||
.shop-bg-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
max-height: 90px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 8px;
|
||||
border: 1.5px solid var(--border);
|
||||
}
|
||||
.shop-item-name {
|
||||
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
|
||||
color: var(--text); line-height: 1.3;
|
||||
@@ -1008,6 +1019,7 @@
|
||||
<div class="shop-filters">
|
||||
<button class="shop-filter active" data-type="all" onclick="shopFilter('all',this)">Все</button>
|
||||
<button class="shop-filter" data-type="frame" onclick="shopFilter('frame',this)">Рамки</button>
|
||||
<button class="shop-filter" data-type="background" onclick="shopFilter('background',this)">Фоны</button>
|
||||
<button class="shop-filter" data-type="title" onclick="shopFilter('title',this)">Титулы</button>
|
||||
<button class="shop-filter" data-type="effect" onclick="shopFilter('effect',this)">Эффекты</button>
|
||||
</div>
|
||||
@@ -1530,6 +1542,12 @@
|
||||
}
|
||||
|
||||
function _isItemActive(item) {
|
||||
if (item.type === 'background' && _activeCosmetics.background) {
|
||||
try {
|
||||
const data = JSON.parse(item.data || '{}');
|
||||
return data.slug === _activeCosmetics.background.slug;
|
||||
} catch { return false; }
|
||||
}
|
||||
if (item.type === 'title' && _activeCosmetics.title) return true;
|
||||
if (item.type === 'effect' && _activeCosmetics.effect) return true;
|
||||
if (item.type === 'frame' && _activeCosmetics.frame) return true;
|
||||
@@ -1549,7 +1567,9 @@
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = items.map(item => {
|
||||
const owned = !!item.owned;
|
||||
// Free items (price === 0) are auto-owned for everyone: backgrounds
|
||||
// and any future freebies don't need a user_purchases row.
|
||||
const owned = !!item.owned || item.price === 0;
|
||||
const canBuy = _shopCoins >= item.price && !owned;
|
||||
const active = owned && _isItemActive(item);
|
||||
let btn = '';
|
||||
@@ -1591,6 +1611,13 @@
|
||||
const text = titleData.text || item.name;
|
||||
return `<div class="shop-title-preview" style="color:${esc(color)}">${esc(text)}</div>`;
|
||||
}
|
||||
if (item.type === 'background') {
|
||||
let bg = {};
|
||||
try { bg = JSON.parse(item.data || '{}'); } catch {}
|
||||
const slug = (bg.slug || 'none').replace(/[^a-z0-9_-]/gi, '');
|
||||
// 56px swatch with the same CSS rules as the full-screen bg.
|
||||
return `<div class="shop-bg-preview bg-preview bg-${slug}"></div>`;
|
||||
}
|
||||
// effects / other — fall back to the lucide icon
|
||||
return `<div class="shop-item-icon">${LS.icon(item.icon || 'star', 28)}</div>`;
|
||||
}
|
||||
|
||||
@@ -1078,6 +1078,23 @@ async function applyCosmetics() {
|
||||
const c = await getMyActiveCosmetics();
|
||||
if (!c) return;
|
||||
|
||||
// ── Background: paint a fixed div behind the whole UI ──
|
||||
// The element is created on demand and reused on subsequent calls
|
||||
// so swapping backgrounds doesn't flash.
|
||||
if (c.background && c.background.slug && c.background.slug !== 'none') {
|
||||
let bgEl = document.getElementById('ls-bg-fx');
|
||||
if (!bgEl) {
|
||||
bgEl = document.createElement('div');
|
||||
bgEl.id = 'ls-bg-fx';
|
||||
document.body.insertBefore(bgEl, document.body.firstChild);
|
||||
}
|
||||
bgEl.className = 'bg-' + String(c.background.slug).replace(/[^a-z0-9_-]/gi, '');
|
||||
} else {
|
||||
// Active bg was cleared — remove the element if present.
|
||||
const bgEl = document.getElementById('ls-bg-fx');
|
||||
if (bgEl) bgEl.remove();
|
||||
}
|
||||
|
||||
// ── Frame: apply CSS to sidebar avatar on every page ──
|
||||
if (c.frame && c.frame.css) {
|
||||
const navAv = document.getElementById('nav-avatar');
|
||||
|
||||
Reference in New Issue
Block a user