Revert "UI redesign: CS2 gambling aesthetic, glassmorphism, responsive layout, enhanced sounds, admin panel overhaul"

This reverts commit 4b0fc594b4.
This commit is contained in:
root
2026-07-05 14:42:58 +00:00
parent 4b0fc594b4
commit a34c9f07a7
21 changed files with 9157 additions and 2704 deletions
+4413 -525
View File
File diff suppressed because it is too large Load Diff
+128 -105
View File
@@ -1,132 +1,155 @@
// ─── Shared CS2 Simulator Utilities ─────────────────────────────────────────
// Logout
async function logout() {
try { await fetch('/web/api/auth/logout', { method: 'POST' }); } catch (e) {}
window.location.href = '/';
try {
await fetch('/web/api/auth/logout', { method: 'POST' });
} catch (e) {}
window.location.href = '/';
}
// Refresh balance display
async function refreshBalance() {
try {
const res = await fetch('/web/api/user/balance');
const data = await res.json();
if (data.success) {
document.querySelectorAll('.user-balance').forEach(el => {
const old = el.textContent;
const val = `${Math.floor(data.balance)}`;
if (old !== val) {
el.textContent = val;
el.classList.remove('animate-scaleIn');
void el.offsetWidth;
el.classList.add('animate-scaleIn');
try {
const res = await fetch('/web/api/user/balance');
const data = await res.json();
if (data.success) {
document.querySelectorAll('.user-balance').forEach(el => {
el.textContent = `${Math.floor(data.balance)}`;
});
}
});
}
} catch (e) {}
} catch (e) {}
}
// Rarity colors
function getRarityColor(rarity) {
const colors = {
'Consumer Grade': '#b0b0b0',
'Industrial Grade': '#5e98d9',
'Mil-Spec': '#4b69ff',
'Mil-Spec Grade': '#4b69ff',
'Restricted': '#8847ff',
'Classified': '#d32ce6',
'Covert': '#eb4b4b',
'Rare Special Item': '#ffd700',
'Extraordinary': '#ffd700',
};
return colors[rarity] || '#b0b0b0';
const colors = {
'Consumer Grade': '#b0b0b0',
'Industrial Grade': '#5e98d9',
'Mil-Spec': '#4b69ff',
'Mil-Spec Grade': '#4b69ff',
'Restricted': '#8847ff',
'Classified': '#d32ce6',
'Covert': '#eb4b4b',
'Rare Special Item': '#ffd700',
'Extraordinary': '#ffd700',
};
return colors[rarity] || '#b0b0b0';
}
// Format number with spaces
function formatNumber(n) {
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
// Debounce
function debounce(fn, ms = 300) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}
// Escape HTML
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Show notification
function showNotification(message, type = 'info', duration = 3000) {
let container = document.getElementById('toastContainer');
if (!container) {
container = document.createElement('div');
container.id = 'toastContainer';
container.className = 'toast-container';
document.body.appendChild(container);
}
const existing = document.querySelector('.notification-toast');
if (existing) existing.remove();
const icons = { success: '✓', error: '✗', info: '', warning: '⚠' };
const toast = document.createElement('div');
toast.className = `toast toast-${type} animate-slideUp`;
toast.innerHTML = `<span>${icons[type] || ''}</span><span>${message}</span>`;
container.appendChild(toast);
const colors = {
success: '#22c55e',
error: '#ef4444',
info: '#3b82f6',
warning: '#f59e0b',
};
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, duration);
}
function showAchievementPopup(title, reward) {
let popup = document.getElementById('achievementGlobalPopup');
if (!popup) {
popup = document.createElement('div');
popup.id = 'achievementGlobalPopup';
popup.className = 'modal-overlay';
popup.style.background = 'rgba(0,0,0,0.5)';
popup.style.alignItems = 'flex-start';
popup.style.paddingTop = '60px';
popup.innerHTML = `
<div style="background:linear-gradient(135deg,#1a3a1a,#0d260d);border:1px solid #22c55e;border-radius:14px;padding:1.25rem 1.75rem;max-width:400px;width:90%;text-align:center;animation:modalSlideIn 0.3s ease;box-shadow:0 16px 48px rgba(0,0,0,0.6);">
<div style="font-size:0.8rem;color:var(--text-dim,#94a3b8);margin-bottom:0.25rem;text-transform:uppercase;letter-spacing:0.05em;">🏆 Достижение разблокировано!</div>
<div style="font-size:1.2rem;font-weight:600;font-family:var(--font-display,'Russo One',sans-serif);" id="globalPopupTitle"></div>
<div style="color:#22c55e;font-size:0.95rem;margin-top:0.35rem;font-weight:600;" id="globalPopupReward"></div>
</div>
const toast = document.createElement('div');
toast.className = 'notification-toast';
toast.style.cssText = `
position: fixed; top: 20px; right: 20px; z-index: 99999;
background: #1a1a2e; border: 1px solid ${colors[type] || colors.info};
color: white; padding: 12px 20px; border-radius: 8px;
font-size: 14px; box-shadow: 0 10px 40px rgba(0,0,0,0.5);
animation: slideInRight 0.3s ease; max-width: 400px;
`;
document.body.appendChild(popup);
}
toast.textContent = message;
document.body.appendChild(toast);
document.getElementById('globalPopupTitle').textContent = title;
document.getElementById('globalPopupReward').textContent = reward ? `+${reward}` : '';
popup.style.display = 'flex';
if (window.SoundManager) SoundManager.achievement();
setTimeout(() => {
popup.style.display = 'none';
}, 4000);
setTimeout(() => {
toast.style.animation = 'slideOutRight 0.3s ease forwards';
setTimeout(() => toast.remove(), 300);
}, duration);
}
document.addEventListener('DOMContentLoaded', () => {
const toggle = document.getElementById('mobileToggle');
const nav = document.getElementById('navLinks');
if (toggle && nav) {
toggle.addEventListener('click', () => {
nav.classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!toggle.contains(e.target) && !nav.contains(e.target)) {
nav.classList.remove('open');
}
});
}
// Inject notification styles once
if (!document.getElementById('notificationStyles')) {
const style = document.createElement('style');
style.id = 'notificationStyles';
style.textContent = `
@keyframes slideInRight {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOutRight {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(120%); opacity: 0; }
}
`;
document.head.appendChild(style);
}
const soundToggle = document.getElementById('soundToggle');
if (soundToggle && window.SoundManager) {
soundToggle.addEventListener('click', () => {
const enabled = SoundManager.toggle();
soundToggle.textContent = enabled ? '🔊' : '🔇';
});
}
});
// Show achievement popup (global function used by templates)
function showAchievementPopup(title, reward) {
let popup = document.getElementById('achievementGlobalPopup');
if (!popup) {
popup = document.createElement('div');
popup.id = 'achievementGlobalPopup';
popup.style.cssText = `
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
z-index: 99999; background: linear-gradient(135deg, #1a3a1a, #0d260d);
border: 1px solid #22c55e; border-radius: 12px; padding: 1rem 1.5rem;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
animation: slideInDown 0.5s ease; max-width: 400px; width: 90%;
text-align: center;
`;
popup.innerHTML = `
<div style="font-size:0.85rem;color:#94a3b8;margin-bottom:0.25rem;">🏆 Новое достижение!</div>
<div style="font-size:1.1rem;font-weight:600;" id="globalPopupTitle"></div>
<div style="color:#22c55e;font-size:0.9rem;margin-top:0.25rem;" id="globalPopupReward"></div>
`;
document.body.appendChild(popup);
if (!document.getElementById('slideInDownStyle')) {
const s = document.createElement('style');
s.id = 'slideInDownStyle';
s.textContent = `
@keyframes slideInDown {
from { transform: translateX(-50%) translateY(-120%); opacity: 0; }
to { transform: translateX(-50%) translateY(0); opacity: 1; }
}
`;
document.head.appendChild(s);
}
}
document.getElementById('globalPopupTitle').textContent = title;
document.getElementById('globalPopupReward').textContent = reward ? `+${reward}` : '';
popup.style.display = 'block';
if (window.SoundManager) SoundManager.achievement();
setTimeout(() => {
popup.style.animation = 'slideInDown 0.5s ease reverse forwards';
setTimeout(() => {
popup.style.display = 'none';
popup.style.animation = 'slideInDown 0.5s ease';
}, 500);
}, 4000);
}
+179 -343
View File
@@ -1,349 +1,185 @@
// Sound effects system using Web Audio API - no external files needed
const SoundManager = {
_ctx: null,
_master: null,
_analyser: null,
_enabled: true,
_volume: 0.3,
_ambient: null,
_ambientGain: null,
_initCalled: false,
_ctx: null,
_enabled: true,
_volume: 0.3,
init() {
if (this._initCalled) return;
this._initCalled = true;
try {
this._ctx = new (window.AudioContext || window.webkitAudioContext)();
this._master = this._ctx.createGain();
this._master.gain.value = this._volume;
this._master.connect(this._ctx.destination);
} catch (e) {
console.warn('Web Audio API not supported');
this._enabled = false;
init() {
try {
this._ctx = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) {
console.warn('Web Audio API not supported');
this._enabled = false;
}
const saved = localStorage.getItem('sound_enabled');
if (saved !== null) this._enabled = saved === 'true';
},
setEnabled(on) {
this._enabled = on;
localStorage.setItem('sound_enabled', on);
},
toggle() {
this.setEnabled(!this._enabled);
return this._enabled;
},
_play(freq, duration, type = 'sine', volume = 1) {
if (!this._enabled || !this._ctx) return;
if (this._ctx.state === 'suspended') this._ctx.resume();
const osc = this._ctx.createOscillator();
const gain = this._ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, this._ctx.currentTime);
gain.gain.setValueAtTime(this._volume * volume, this._ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this._ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this._ctx.destination);
osc.start();
osc.stop(this._ctx.currentTime + duration);
},
_noise(duration, volume = 1) {
if (!this._enabled || !this._ctx) return;
if (this._ctx.state === 'suspended') this._ctx.resume();
const bufferSize = this._ctx.sampleRate * duration;
const buffer = this._ctx.createBuffer(1, bufferSize, this._ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = this._ctx.createBufferSource();
source.buffer = buffer;
const gain = this._ctx.createGain();
gain.gain.setValueAtTime(this._volume * volume, this._ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this._ctx.currentTime + duration);
source.connect(gain);
gain.connect(this._ctx.destination);
source.start();
},
caseOpen() {
// Descending sweep with click
this._play(800, 0.1, 'square', 0.3);
setTimeout(() => this._play(400, 0.15, 'sawtooth', 0.2), 80);
setTimeout(() => this._play(200, 0.2, 'sine', 0.15), 180);
this._noise(0.05, 0.4);
},
caseRare() {
// Ascending bright chime for rare items
this._play(523, 0.15, 'sine', 0.3);
setTimeout(() => this._play(659, 0.15, 'sine', 0.3), 100);
setTimeout(() => this._play(784, 0.15, 'sine', 0.3), 200);
setTimeout(() => this._play(1047, 0.3, 'sine', 0.4), 300);
},
caseCovert() {
// Epic sound for covert/knife
this._play(392, 0.2, 'sawtooth', 0.3);
setTimeout(() => this._play(523, 0.2, 'sawtooth', 0.3), 150);
setTimeout(() => this._play(659, 0.2, 'sawtooth', 0.3), 300);
setTimeout(() => this._play(784, 0.4, 'sine', 0.5), 450);
setTimeout(() => this._play(1047, 0.6, 'sine', 0.6), 600);
},
slotSpin() {
// Ratcheting sound for slot reels
for (let i = 0; i < 8; i++) {
setTimeout(() => {
this._play(300 + Math.random() * 400, 0.05, 'square', 0.15);
}, i * 80);
}
},
slotMatch() {
// Win jingle
this._play(523, 0.1, 'sine', 0.3);
setTimeout(() => this._play(659, 0.1, 'sine', 0.3), 100);
setTimeout(() => this._play(784, 0.1, 'sine', 0.3), 200);
setTimeout(() => this._play(1047, 0.4, 'sine', 0.5), 300);
},
contractSubmit() {
// Mechanical sound
this._play(150, 0.3, 'sawtooth', 0.2);
setTimeout(() => this._play(200, 0.2, 'square', 0.2), 200);
setTimeout(() => this._play(250, 0.1, 'square', 0.15), 350);
},
contractResult() {
// Rising success
this._play(400, 0.15, 'sine', 0.3);
setTimeout(() => this._play(600, 0.15, 'sine', 0.3), 120);
setTimeout(() => this._play(800, 0.3, 'sine', 0.4), 240);
},
wheelSpin() {
// Ticking as wheel rotates
this._play(200, 0.03, 'square', 0.1);
},
wheelWin() {
// Celebration
this._play(523, 0.15, 'sine', 0.3);
setTimeout(() => this._play(659, 0.15, 'sine', 0.3), 120);
setTimeout(() => this._play(784, 0.2, 'sine', 0.35), 240);
setTimeout(() => this._play(1047, 0.5, 'sine', 0.5), 360);
},
wheelLose() {
// Sad trombone
this._play(400, 0.2, 'sine', 0.2);
setTimeout(() => this._play(350, 0.2, 'sine', 0.2), 150);
setTimeout(() => this._play(300, 0.3, 'sine', 0.15), 300);
},
achievement() {
// Achievement unlock
this._play(659, 0.1, 'sine', 0.3);
setTimeout(() => this._play(523, 0.1, 'sine', 0.3), 100);
setTimeout(() => this._play(784, 0.1, 'sine', 0.3), 200);
setTimeout(() => this._play(659, 0.1, 'sine', 0.3), 300);
setTimeout(() => this._play(1047, 0.4, 'sine', 0.5), 400);
},
crashBet() {
this._play(300, 0.1, 'square', 0.15);
},
crashTick() {
this._play(500 + Math.random() * 500, 0.03, 'sine', 0.08);
},
crashCrashed() {
// Explosion
this._noise(0.3, 0.6);
this._play(80, 0.5, 'sawtooth', 0.4);
setTimeout(() => this._play(50, 0.8, 'sine', 0.3), 100);
},
crashCashout() {
// Cashout success
this._play(600, 0.1, 'sine', 0.3);
setTimeout(() => this._play(800, 0.1, 'sine', 0.3), 80);
setTimeout(() => this._play(1000, 0.2, 'sine', 0.4), 160);
},
click() {
this._play(800, 0.03, 'sine', 0.1);
},
error() {
this._play(200, 0.15, 'square', 0.2);
},
balanceUpdate() {
this._play(1000, 0.05, 'sine', 0.1);
}
const saved = localStorage.getItem('sound_enabled');
if (saved !== null) this._enabled = saved === 'true';
const vol = localStorage.getItem('sound_volume');
if (vol !== null) this.setVolume(parseFloat(vol));
document.addEventListener('click', () => {
if (this._ctx && this._ctx.state === 'suspended') this._ctx.resume();
}, { once: true });
},
setEnabled(on) {
this._enabled = on;
localStorage.setItem('sound_enabled', on);
if (!on && this._ambientGain) {
this._ambientGain.gain.setTargetAtTime(0, this._ctx.currentTime, 0.5);
}
},
toggle() {
this.setEnabled(!this._enabled);
return this._enabled;
},
setVolume(v) {
this._volume = Math.max(0, Math.min(1, v));
if (this._master) this._master.gain.value = this._volume;
localStorage.setItem('sound_volume', this._volume);
},
_t() {
return this._ctx ? this._ctx.currentTime : 0;
},
_osc(freq, duration, type = 'sine', vol = 1, dest) {
if (!this._enabled || !this._ctx) return;
if (this._ctx.state === 'suspended') this._ctx.resume();
const now = this._t();
const o = this._ctx.createOscillator();
const g = this._ctx.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, now);
g.gain.setValueAtTime(vol, now);
g.gain.exponentialRampToValueAtTime(0.001, now + duration);
o.connect(g);
g.connect(dest || this._master);
o.start(now);
o.stop(now + duration);
},
_noise(duration, vol = 1, filterFreq, dest) {
if (!this._enabled || !this._ctx) return;
if (this._ctx.state === 'suspended') this._ctx.resume();
const now = this._t();
const sr = this._ctx.sampleRate;
const len = sr * duration;
const buf = this._ctx.createBuffer(1, len, sr);
const data = buf.getChannelData(0);
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
const src = this._ctx.createBufferSource();
src.buffer = buf;
let node = src;
if (filterFreq) {
const f = this._ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.value = filterFreq;
src.connect(f);
node = f;
}
const g = this._ctx.createGain();
g.gain.setValueAtTime(vol, now);
g.gain.exponentialRampToValueAtTime(0.001, now + duration);
node.connect(g);
g.connect(dest || this._master);
src.start(now);
},
_chord(freqs, duration, type = 'sine', vol = 0.15) {
freqs.forEach(f => this._osc(f, duration, type, vol / freqs.length));
},
// ── Rarity color helpers ──
_rarityParams(rarity) {
const map = {
'Consumer Grade': { baseFreq: 300, arp: [300, 350, 400], noiseVol: 0.2, dur: 0.6 },
'Industrial Grade': { baseFreq: 400, arp: [400, 500, 600], noiseVol: 0.2, dur: 0.7 },
'Mil-Spec': { baseFreq: 500, arp: [500, 630, 750], noiseVol: 0.25, dur: 0.8 },
'Mil-Spec Grade': { baseFreq: 500, arp: [500, 630, 750], noiseVol: 0.25, dur: 0.8 },
'Restricted': { baseFreq: 600, arp: [600, 750, 900], noiseVol: 0.3, dur: 1.0 },
'Classified': { baseFreq: 700, arp: [700, 880, 1050], noiseVol: 0.35, dur: 1.2 },
'Covert': { baseFreq: 800, arp: [800, 1000, 1200], noiseVol: 0.4, dur: 1.5 },
'Rare Special Item': { baseFreq: 900, arp: [900, 1100, 1400], noiseVol: 0.45, dur: 1.8 },
'Extraordinary': { baseFreq: 900, arp: [900, 1100, 1400], noiseVol: 0.45, dur: 1.8 },
};
return map[rarity] || map['Consumer Grade'];
},
// ── Ambient ──
startAmbient() {
if (!this._ctx || this._ambient) return;
const now = this._t();
this._ambientGain = this._ctx.createGain();
this._ambientGain.gain.value = 0;
const bufLen = this._ctx.sampleRate * 2;
const buf = this._ctx.createBuffer(1, bufLen, this._ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufLen; i++) {
const t = i / this._ctx.sampleRate;
data[i] = (Math.random() - 0.5) * 0.5
+ Math.sin(2 * Math.PI * 55 * t) * 0.08
+ Math.sin(2 * Math.PI * 82.5 * t) * 0.05;
}
const src = this._ctx.createBufferSource();
src.buffer = buf;
src.loop = true;
const lp = this._ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 300;
const hp = this._ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 40;
src.connect(lp);
lp.connect(hp);
hp.connect(this._ambientGain);
this._ambientGain.connect(this._master);
src.start();
this._ambient = src;
this._ambientGain.gain.setTargetAtTime(0.06, now, 1);
},
stopAmbient() {
if (this._ambientGain) {
this._ambientGain.gain.setTargetAtTime(0, this._t(), 0.5);
setTimeout(() => {
if (this._ambient) { try { this._ambient.stop(); } catch (e) {} this._ambient = null; }
}, 1500);
}
},
// ── Case Opening ──
caseOpen() {
this._noise(0.15, 0.4, 2000);
this._osc(700, 0.06, 'square', 0.25);
setTimeout(() => this._osc(500, 0.08, 'square', 0.2), 50);
setTimeout(() => this._osc(350, 0.1, 'sawtooth', 0.18), 110);
setTimeout(() => this._osc(250, 0.12, 'sine', 0.12), 180);
setTimeout(() => this._noise(0.3, 0.15, 4000), 120);
},
caseReveal(rarity) {
const p = this._rarityParams(rarity);
this._noise(0.08, 0.3, 3000);
p.arp.forEach((f, i) => {
setTimeout(() => {
this._osc(f, 0.15, 'sine', 0.25);
this._osc(f * 2, 0.1, 'sine', 0.08);
}, i * 100);
});
setTimeout(() => {
this._osc(p.baseFreq * 2, 0.4, 'sine', 0.35);
this._noise(0.15, p.noiseVol, 5000);
this._osc(p.baseFreq, 0.6, 'sawtooth', 0.15);
}, p.arp.length * 100);
},
caseCovert() {
this._noise(0.1, 0.5, 4000);
[392, 523, 659, 784, 1047].forEach((f, i) => {
setTimeout(() => {
this._osc(f, 0.2, 'sawtooth', 0.3);
this._osc(f * 1.5, 0.15, 'sine', 0.1);
}, i * 120);
});
setTimeout(() => {
this._osc(1047, 0.8, 'sine', 0.6);
this._osc(784, 0.6, 'triangle', 0.25);
this._noise(0.6, 0.4, 6000);
}, 600);
},
caseRare() {
[523, 659, 784, 1047].forEach((f, i) => {
setTimeout(() => {
this._osc(f, 0.12, 'sine', 0.3);
this._osc(f * 1.5, 0.08, 'sine', 0.06);
}, i * 80);
});
setTimeout(() => this._osc(1319, 0.5, 'sine', 0.5), 320);
},
// ── Slots ──
slotSpin() {
for (let i = 0; i < 10; i++) {
setTimeout(() => {
this._osc(200 + Math.random() * 600, 0.04, 'square', 0.12);
}, i * 60);
}
},
slotMatch() {
[523, 659, 784, 1047].forEach((f, i) => {
setTimeout(() => this._osc(f, 0.15, 'sine', 0.35), i * 100);
});
setTimeout(() => this._noise(0.2, 0.3, 8000), 300);
},
// ── Contract ──
contractSubmit() {
this._osc(150, 0.25, 'sawtooth', 0.2);
setTimeout(() => this._osc(200, 0.15, 'square', 0.18), 150);
setTimeout(() => {
this._osc(250, 0.08, 'square', 0.15);
this._noise(0.05, 0.15);
}, 280);
},
contractResult() {
[400, 600, 800].forEach((f, i) => {
setTimeout(() => this._osc(f, 0.15, 'sine', 0.3), i * 100);
});
setTimeout(() => {
this._osc(1200, 0.4, 'sine', 0.45);
this._noise(0.2, 0.2, 5000);
}, 300);
},
// ── Wheel ──
wheelTick() {
this._osc(200, 0.03, 'square', 0.08);
},
wheelWin() {
[523, 659, 784, 1047].forEach((f, i) => {
setTimeout(() => this._osc(f, 0.18, 'sine', 0.35), i * 100);
});
setTimeout(() => this._noise(0.4, 0.3, 8000), 400);
},
wheelLose() {
[400, 350, 300].forEach((f, i) => {
setTimeout(() => this._osc(f, 0.25, 'sine', 0.2), i * 150);
});
},
// ── Achievements ──
achievement() {
[659, 523, 784, 659, 1047].forEach((f, i) => {
setTimeout(() => this._osc(f, 0.12, 'sine', 0.3), i * 80);
});
setTimeout(() => this._noise(0.3, 0.25, 6000), 400);
},
// ── Crash ──
crashBet() {
this._osc(300, 0.08, 'square', 0.15);
setTimeout(() => this._osc(350, 0.06, 'square', 0.1), 60);
},
crashTick(multiplier) {
const freq = 300 + Math.min(multiplier * 50, 800);
this._osc(freq, 0.03, 'sine', 0.05 + Math.min(multiplier * 0.01, 0.15));
},
crashCrashed() {
this._noise(0.5, 0.6, 2000);
this._osc(80, 0.6, 'sawtooth', 0.4);
setTimeout(() => this._osc(50, 1, 'sine', 0.3), 100);
setTimeout(() => this._osc(40, 0.8, 'triangle', 0.2), 200);
},
crashCashout(multiplier) {
const vol = 0.2 + Math.min(multiplier * 0.02, 0.5);
[600, 800, 1000, 1200].forEach((f, i) => {
setTimeout(() => this._osc(f, 0.12, 'sine', vol), i * 80);
});
setTimeout(() => this._noise(0.3, 0.3, 8000), 320);
},
// ── Upgrade ──
upgradeSubmit() {
this._osc(250, 0.1, 'square', 0.15);
this._noise(0.05, 0.2);
},
upgradeResult() {
[523, 784, 1047].forEach((f, i) => {
setTimeout(() => this._osc(f, 0.2, 'sine', 0.3), i * 120);
});
setTimeout(() => this._noise(0.25, 0.25, 6000), 360);
},
upgradeFail() {
[300, 250, 200].forEach((f, i) => {
setTimeout(() => this._osc(f, 0.2, 'sawtooth', 0.2), i * 120);
});
},
// ── UI ──
click() {
this._osc(800, 0.03, 'sine', 0.08);
},
hover() {
this._osc(1200, 0.015, 'sine', 0.03);
},
error() {
this._osc(200, 0.15, 'square', 0.2);
setTimeout(() => this._osc(180, 0.12, 'square', 0.15), 100);
},
success() {
[600, 800, 1000].forEach((f, i) => {
setTimeout(() => this._osc(f, 0.08, 'sine', 0.2), i * 60);
});
},
balanceUpdate() {
this._osc(1000, 0.04, 'sine', 0.08);
setTimeout(() => this._osc(1200, 0.04, 'sine', 0.06), 50);
},
};
// Auto-init
document.addEventListener('DOMContentLoaded', () => SoundManager.init());
+205 -50
View File
@@ -1,86 +1,203 @@
<style>
.activity-sidebar {
position: fixed; left: 0; top: 0; bottom: 0; width: 240px;
background: var(--bg-card); border-right: 1px solid var(--border);
z-index: 40; display: flex; flex-direction: column; overflow: hidden;
padding-top: 56px; transform: translateX(0);
transition: transform 0.3s cubic-bezier(0.4,0,0.2,1);
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 260px;
background: var(--bg-card);
border-right: 1px solid var(--border-color);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
padding-top: 60px;
transform: translateX(0);
transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
}
.activity-sidebar.closed {
transform: translateX(-100%);
}
.activity-sidebar.closed { transform: translateX(-100%); }
.activity-sidebar-header {
padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center; flex-shrink: 0;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.activity-sidebar-header h3 {
font-size: 0.8rem;
margin: 0;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.35rem;
}
.live-dot {
width: 5px; height: 5px;
background: #22c55e;
border-radius: 50%;
display: inline-block;
animation: pulse 1.5s ease infinite;
}
@keyframes pulse {
0%,100%{opacity:1}50%{opacity:0.3}
}
.activity-sidebar-list {
flex: 1;
overflow-y: auto;
padding: 0.4rem;
}
.activity-sidebar-list { flex: 1; overflow-y: auto; padding: 0.35rem; }
.activity-sidebar-list::-webkit-scrollbar { width: 3px; }
.activity-sidebar-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.activity-sidebar-list::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 2px; }
.activity-sidebar-item {
display: flex; gap: 0.4rem; padding: 0.35rem 0.5rem; border-radius: 6px;
margin-bottom: 0.1rem; cursor: pointer; transition: background 0.15s;
display: flex;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
border-radius: 6px;
margin-bottom: 0.15rem;
transition: background 0.15s;
animation: fadeInItem 0.3s ease;
}
.activity-sidebar-item:hover { background: var(--bg-hover); }
.activity-sidebar-item .asi-icon { font-size: 0.8rem; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: var(--bg-dark); border-radius: 4px; flex-shrink: 0; }
.activity-sidebar-item .asi-content { flex: 1; min-width: 0; font-size: 0.68rem; line-height: 1.25; }
.activity-sidebar-item .asi-content .asi-username { color: var(--primary); font-weight: 500; }
.activity-sidebar-item .asi-content .asi-time { font-size: 0.55rem; color: var(--text-dim); margin-top: 0.05rem; }
.activity-sidebar-item .asi-item-preview { width: 26px; height: 20px; object-fit: contain; border-radius: 3px; flex-shrink: 0; }
.activity-toggle-btn {
position: fixed; left: 0; top: 50%; transform: translateY(-50%);
background: var(--bg-card); border: 1px solid var(--border); border-left: none;
border-radius: 0 8px 8px 0; padding: 0.5rem 0.2rem; cursor: pointer;
z-index: 39; font-size: 0.75rem; color: var(--text-dim);
writing-mode: vertical-rl; letter-spacing: 0.04em;
display: none; user-select: none;
.activity-sidebar-item:hover { background: var(--bg-dark); }
.activity-sidebar-item .asi-icon {
font-size: 0.85rem;
width: 24px; height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-dark);
border-radius: 5px;
flex-shrink: 0;
}
.activity-toggle-btn:hover { color: var(--primary); border-color: var(--primary); }
.activity-toggle-btn.visible { display: flex; align-items: center; gap: 0.2rem; }
@keyframes fadeInItem { from { opacity:0; transform:translateY(-4px); } to { opacity:1; transform:translateY(0); } }
.has-activity-sidebar { padding-left: 240px; transition: padding-left 0.3s cubic-bezier(0.4,0,0.2,1); }
.has-activity-sidebar.sidebar-closed { padding-left: 0; }
@media (max-width:900px) {
.activity-sidebar { display: none; } .activity-toggle-btn { display: none !important; } .has-activity-sidebar { padding-left: 0; }
.activity-sidebar-item .asi-content {
flex: 1;
min-width: 0;
font-size: 0.72rem;
line-height: 1.25;
}
.activity-sidebar-item .asi-content .asi-username {
color: var(--primary-color);
font-weight: 500;
}
.activity-sidebar-item .asi-content .asi-time {
font-size: 0.6rem;
color: var(--text-secondary);
margin-top: 0.1rem;
}
.activity-sidebar-item .asi-item-preview {
width: 28px; height: 21px;
object-fit: contain;
border-radius: 3px;
flex-shrink: 0;
}
.activity-sidebar-close {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 1rem;
padding: 0.2rem;
line-height: 1;
opacity: 0.5;
transition: opacity 0.2s;
}
.activity-sidebar-close:hover { opacity: 1; }
/* Кнопка открытия ленты (слева, по центру) */
.activity-toggle-btn {
position: fixed;
left: 0;
top: 50%;
transform: translateY(-50%);
background: var(--bg-card);
border: 1px solid var(--border-color);
border-left: none;
border-radius: 0 8px 8px 0;
padding: 0.6rem 0.3rem;
cursor: pointer;
z-index: 49;
font-size: 0.85rem;
color: var(--text-secondary);
transition: all 0.2s;
writing-mode: vertical-rl;
letter-spacing: 0.05em;
display: none;
user-select: none;
}
.activity-toggle-btn:hover {
color: var(--primary-color);
border-color: var(--primary-color);
}
.activity-toggle-btn.visible {
display: flex;
align-items: center;
gap: 0.3rem;
}
@keyframes fadeInItem {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
.has-activity-sidebar {
padding-left: 260px;
transition: padding-left 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
}
.has-activity-sidebar.sidebar-closed {
padding-left: 0;
}
@media (max-width: 900px) {
.activity-sidebar { display: none; }
.activity-toggle-btn { display: none !important; }
.has-activity-sidebar { padding-left: 0; }
}
</style>
<div class="activity-sidebar" id="activitySidebar">
<div class="activity-sidebar-header">
<span class="flex gap-1 items-center" style="font-size:0.75rem;font-weight:600;font-family:var(--font-display);">
<span style="width:5px;height:5px;border-radius:50%;background:#22c55e;display:inline-block;animation:pulse 1.5s ease infinite;"></span>
Лента
</span>
<span class="flex gap-1 items-center text-xs text-dim">
<h3><span class="live-dot"></span> Лента</h3>
<span style="font-size:0.7rem;color:var(--text-secondary);display:flex;align-items:center;gap:0.25rem;">
<span id="onlineCount">0</span> 👤
</span>
<button class="activity-sidebar-close" onclick="toggleActivitySidebar()" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:0.85rem;padding:0.1rem;line-height:1;"></button>
<button class="activity-sidebar-close" onclick="toggleActivitySidebar()" title="Скрыть"></button>
</div>
<div class="activity-sidebar-list" id="activitySidebarList">
<div style="text-align:center;padding:2rem;color:var(--text-dim);font-size:0.78rem;">Загрузка...</div>
<div style="text-align:center;padding:2rem;color:var(--text-secondary);font-size:0.85rem;">Загрузка...</div>
</div>
</div>
<button class="activity-toggle-btn" id="activityToggleBtn" onclick="toggleActivitySidebar()" title="Показать ленту">📰 Лента</button>
<button class="activity-toggle-btn" id="activityToggleBtn" onclick="toggleActivitySidebar()" title="Показать ленту">
📰 Лента
</button>
<script>
document.addEventListener('DOMContentLoaded', async () => {
const list = document.getElementById('activitySidebarList');
const sidebar = document.getElementById('activitySidebar');
document.body.classList.add('has-activity-sidebar');
// Восстанавливаем состояние из localStorage
if (localStorage.getItem('sidebar_closed') === 'true') {
sidebar.classList.add('closed');
document.body.classList.add('sidebar-closed');
document.getElementById('activityToggleBtn').classList.add('visible');
}
WS.on('online_count', (data) => {
document.getElementById('onlineCount').textContent = data.online || 0;
});
try {
const res = await fetch('/web/api/activity?limit=10');
const data = await res.json();
if (data.success && data.activities) renderActivities(data.activities);
if (data.success && data.activities) {
renderActivities(data.activities);
}
} catch (e) {
if (list) list.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-dim);font-size:0.78rem;">Не удалось загрузить</div>';
if (list) list.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-secondary);">Не удалось загрузить</div>';
}
WS.on('activity', (data) => {
const act = data.activity;
if (!act || !list) return;
@@ -89,7 +206,9 @@ document.addEventListener('DOMContentLoaded', async () => {
const item = createActivityItem(act);
list.insertBefore(item, list.firstChild);
if (act.activity_type === 'achievement') SoundManager.achievement();
while (list.children.length > 15) list.removeChild(list.lastChild);
while (list.children.length > 15) {
list.removeChild(list.lastChild);
}
});
});
@@ -98,28 +217,58 @@ const RARITY_COLORS = {
'mil-spec': '#4b69ff', 'mil-spec-grade': '#4b69ff',
'restricted': '#8847ff', 'classified': '#d32ce6',
'covert': '#eb4b4b', 'rare-special-item': '#ffd700',
'rare-special': '#ffd700', 'extraordinary': '#ffd700', 'contraband': '#ffaa00'
'rare-special': '#ffd700', 'extraordinary': '#ffd700',
'contraband': '#ffaa00'
};
function createActivityItem(act) {
const iconMap = { 'case_open': '📦', 'contract': '🔄', 'upgrade': '🆙', 'achievement': '🏆', 'crash': '💥' };
const iconMap = {
'case_open': '📦', 'contract': '🔄', 'upgrade': '🆙',
'achievement': '🏆', 'crash': '💥'
};
const item = document.createElement('div');
item.className = 'activity-sidebar-item';
item.style.cursor = 'pointer';
const raritySlug = (act.data && act.data.rarity || '').toLowerCase().replace(/ /g, '-').replace(/_/g, '-').replace(/™/g, '');
const rarityColor = RARITY_COLORS[raritySlug] || '';
if (rarityColor) { const v = parseInt(rarityColor.replace('#',''),16); item.style.background = `rgba(${(v>>16)},${(v>>8)&255},${v&255},0.08)`; }
if (rarityColor) {
item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.08)`;
}
item.addEventListener('click', () => window.location.href = `/profiles/${act.user_id}`);
const icon = iconMap[act.activity_type] || '📌';
const imgHtml = (act.data && act.data.item_image) ? `<img class="asi-item-preview" src="${act.data.item_image}" onerror="this.style.display='none'">` : '';
const timeStr = act.created_at ? new Date(act.created_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '';
item.innerHTML = `<div class="asi-icon">${icon}</div><div class="asi-content"><span class="asi-username">${escapeHtml(act.username)}</span> ${escapeHtml(act.message)}<div class="asi-time">${timeStr}</div></div>${imgHtml}`;
const imgHtml = (act.data && act.data.item_image)
? `<img class="asi-item-preview" src="${act.data.item_image}" onerror="this.style.display='none'">`
: '';
const timeStr = act.created_at
? new Date(act.created_at).toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'})
: '';
item.innerHTML = `
<div class="asi-icon">${icon}</div>
<div class="asi-content">
<span class="asi-username">${escapeHtml(act.username)}</span>
<span>${escapeHtml(act.message)}</span>
<div class="asi-time">${timeStr}</div>
</div>
${imgHtml}
`;
return item;
}
function hexToRgb(hex) {
const v = parseInt(hex.replace('#', ''), 16);
return (v >> 16) + ',' + ((v >> 8) & 255) + ',' + (v & 255);
}
function renderActivities(activities) {
const list = document.getElementById('activitySidebarList');
if (!list) return;
if (!activities || activities.length === 0) { list.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-dim);font-size:0.78rem;">Пока нет активностей</div>'; return; }
if (!activities || activities.length === 0) {
list.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-secondary);font-size:0.85rem;">Пока нет активностей</div>';
return;
}
list.innerHTML = '';
activities.forEach(a => list.appendChild(createActivityItem(a)));
}
@@ -134,4 +283,10 @@ function toggleActivitySidebar() {
btn.classList.toggle('visible', isOpen);
localStorage.setItem('sidebar_closed', isOpen ? 'true' : 'false');
}
function escapeHtml(text) {
const d = document.createElement('div');
d.textContent = text || '';
return d.innerHTML;
}
</script>
+270 -57
View File
@@ -3,107 +3,320 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Достижения CS2 Simulator</title>
<title>Достижения - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.achievements-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
}
.achievements-stats {
display: flex;
gap: 1.5rem;
}
.achievements-stats .stat {
text-align: center;
}
.achievements-stats .stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-color);
}
.achievements-stats .stat-label {
font-size: 0.8rem;
color: var(--text-secondary);
}
.achievements-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.achievement-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1rem;
display: flex;
gap: 1rem;
align-items: flex-start;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.achievement-card.unlocked {
border-color: var(--success-color);
background: linear-gradient(135deg, rgba(34, 197, 94, 0.05), transparent);
}
.achievement-card.unlocked::before {
content: '';
position: absolute;
top: -50%;
right: -50%;
width: 100%;
height: 100%;
background: radial-gradient(circle, rgba(34, 197, 94, 0.08), transparent 70%);
pointer-events: none;
}
.achievement-card.locked {
opacity: 0.7;
}
.achievement-icon {
font-size: 2rem;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-dark);
border-radius: 10px;
flex-shrink: 0;
}
.achievement-card.unlocked .achievement-icon {
background: rgba(34, 197, 94, 0.15);
}
.achievement-info {
flex: 1;
min-width: 0;
}
.achievement-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.achievement-desc {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.achievement-reward {
font-size: 0.8rem;
color: var(--success-color);
}
.achievement-progress {
margin-top: 0.5rem;
}
.progress-bar {
height: 6px;
background: var(--bg-dark);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary-color);
border-radius: 3px;
transition: width 0.5s ease;
}
.progress-fill.complete {
background: var(--success-color);
}
.progress-text {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.category-filter {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.category-btn {
padding: 0.4rem 1rem;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-color);
border-radius: 20px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.category-btn:hover {
border-color: var(--primary-color);
}
.category-btn.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: white;
}
.new-achievement-popup {
position: fixed;
top: 20px;
right: 20px;
background: linear-gradient(135deg, #1a3a1a, #0d260d);
border: 1px solid var(--success-color);
border-radius: 12px;
padding: 1rem 1.5rem;
z-index: 9999;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
animation: slideInRight 0.5s ease;
max-width: 350px;
}
.new-achievement-popup.hiding {
animation: slideOutRight 0.5s ease forwards;
}
@keyframes slideInRight {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOutRight {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(120%); opacity: 0; }
}
.new-achievement-popup .popup-title {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.new-achievement-popup .popup-achievement {
font-size: 1.1rem;
font-weight: 600;
}
.new-achievement-popup .popup-reward {
color: var(--success-color);
font-size: 0.9rem;
margin-top: 0.25rem;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="container">
<a href="/" class="logo">CS2 <span>Simulator</span></a>
<button class="mobile-toggle" id="mobileToggle"></button>
<div class="nav-links" id="navLinks">
<a href="/" class="logo">🎮 CS2 Simulator</a>
<div class="nav-links">
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</a>
<a href="/crash" class="nav-link">Crash</a>
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
<a href="/crash" class="nav-link">💥 Crash</a>
<a href="/inventory" class="nav-link">Инвентарь</a>
<a href="/achievements" class="nav-link active">Достижения</a>
<a href="/activity" class="nav-link">Лента</a>
<a href="/achievements" class="nav-link active">🏆 Достижения</a>
<a href="/activity" class="nav-link">📰 Лента</a>
<a href="/profile" class="nav-link">Профиль</a>
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-ghost">Выйти</button>
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-outline">Выйти</button>
</div>
</div>
</nav>
<main class="section">
<div class="container">
<div class="section-header">
<div>
<h1 class="section-title">🏆 Достижения</h1>
<p class="section-subtitle">Выполняй задания и получай награды</p>
<main class="container" style="padding: 2rem 1rem;">
<div class="achievements-header">
<div>
<h1>🏆 Достижения</h1>
<p class="page-subtitle">Выполняй задания и получай награды</p>
</div>
<div class="achievements-stats">
<div class="stat">
<div class="stat-value">{{ unlocked }}</div>
<div class="stat-label">Открыто</div>
</div>
<div class="flex gap-3">
<div class="stat-card" style="padding:0.5rem 1rem;">
<div class="stat-value" style="font-size:1.1rem;">{{ unlocked }}/{{ total }}</div>
<div class="stat-label text-xs">Открыто</div>
</div>
<div class="stat-card" style="padding:0.5rem 1rem;">
<div class="stat-value" style="font-size:1.1rem;">{{ "%.0f"|format(unlocked/total*100 if total > 0 else 0) }}%</div>
<div class="stat-label text-xs">Прогресс</div>
</div>
<div class="stat">
<div class="stat-value">{{ total }}</div>
<div class="stat-label">Всего</div>
</div>
<div class="stat">
<div class="stat-value">{{ "%.0f"|format(unlocked/total*100 if total > 0 else 0) }}%</div>
<div class="stat-label">Прогресс</div>
</div>
</div>
</div>
<div class="section-tabs" id="categoryFilter">
<button class="section-tab active" data-cat="all">Все</button>
<button class="section-tab" data-cat="cases">📦 Кейсы</button>
<button class="section-tab" data-cat="contracts">🔄 Контракты</button>
<button class="section-tab" data-cat="upgrade">🆙 Апгрейд</button>
<button class="section-tab" data-cat="crash">💥 Crash</button>
<button class="section-tab" data-cat="general">🎯 Общие</button>
</div>
<div class="category-filter" id="categoryFilter">
<button class="category-btn active" data-cat="all">Все</button>
<button class="category-btn" data-cat="cases">📦 Кейсы</button>
<button class="category-btn" data-cat="contracts">🔄 Контракты</button>
<button class="category-btn" data-cat="upgrade">🆙 Апгрейд</button>
<button class="category-btn" data-cat="crash">💥 Crash</button>
<button class="category-btn" data-cat="general">🎯 Общие</button>
</div>
<div class="grid grid-auto" id="achievementsGrid">
{% for ach in achievements %}
<div class="card {{ 'card-glow' if ach.unlocked else '' }}"
data-category="{{ ach.category }}" style="display:flex;gap:0.75rem;align-items:flex-start;">
<div style="font-size:1.8rem;flex-shrink:0;width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--bg-dark);border-radius:var(--radius-sm);{{ 'background:rgba(34,197,94,0.12);' if ach.unlocked }}">
{{ ach.icon }}
</div>
<div style="flex:1;min-width:0;">
<div class="display-font" style="font-size:0.85rem;margin-bottom:0.15rem;">{{ ach.title }}</div>
<div class="text-dim text-sm" style="margin-bottom:0.35rem;">{{ ach.description }}</div>
{% if ach.reward_amount > 0 %}
<div class="badge badge-green" style="margin-bottom:0.5rem;">+{{ "%.0f"|format(ach.reward_amount) }} ₽</div>
{% endif %}
<div>
<div style="height:5px;background:var(--bg-dark);border-radius:3px;overflow:hidden;">
<div style="height:100%;width:{{ (ach.progress / ach.requirement_value * 100)|round }}%;background:{{ 'var(--success)' if ach.unlocked else 'var(--primary)' }};border-radius:3px;transition:width 0.5s ease;"></div>
</div>
<div class="text-xs text-dim" style="margin-top:0.15rem;">
{% if ach.unlocked %}✅ Получено{% else %}{{ ach.progress }} / {{ ach.requirement_value }}{% endif %}
</div>
<div class="achievements-grid" id="achievementsGrid">
{% for ach in achievements %}
<div class="achievement-card {{ 'unlocked' if ach.unlocked else 'locked' }}"
data-category="{{ ach.category }}">
<div class="achievement-icon">{{ ach.icon }}</div>
<div class="achievement-info">
<div class="achievement-title">{{ ach.title }}</div>
<div class="achievement-desc">{{ ach.description }}</div>
{% if ach.reward_amount > 0 %}
<div class="achievement-reward">+{{ "%.0f"|format(ach.reward_amount) }}</div>
{% endif %}
<div class="achievement-progress">
<div class="progress-bar">
<div class="progress-fill {{ 'complete' if ach.unlocked else '' }}"
style="width: {{ (ach.progress / ach.requirement_value * 100)|round }}%"></div>
</div>
<div class="progress-text">
{% if ach.unlocked %}
✅ Получено
{% else %}
{{ ach.progress }} / {{ ach.requirement_value }}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</main>
<div id="achievementPopup" class="new-achievement-popup" style="display:none;">
<div class="popup-title">🏆 Новое достижение!</div>
<div class="popup-achievement" id="popupTitle"></div>
<div class="popup-reward" id="popupReward"></div>
</div>
<script src="/static/js/sounds.js"></script>
<script src="/static/js/websocket.js"></script>
<script src="/static/js/safemode.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('#categoryFilter .section-tab').forEach(btn => {
// Категории
document.querySelectorAll('.category-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#categoryFilter .section-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.category-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const cat = btn.dataset.cat;
document.querySelectorAll('.achievement-card, .card[data-category]').forEach(card => {
document.querySelectorAll('.achievement-card').forEach(card => {
card.style.display = (cat === 'all' || card.dataset.category === cat) ? 'flex' : 'none';
});
});
});
});
function showAchievementPopup(title, reward) {
const popup = document.getElementById('achievementPopup');
document.getElementById('popupTitle').textContent = title;
document.getElementById('popupReward').textContent = reward ? `+${reward} ₽` : '';
popup.style.display = 'block';
SoundManager.achievement();
setTimeout(() => {
popup.classList.add('hiding');
setTimeout(() => {
popup.style.display = 'none';
popup.classList.remove('hiding');
}, 500);
}, 4000);
}
async function logout() {
await fetch('/web/api/auth/logout', { method: 'POST' });
window.location.href = '/';
}
</script>
<script>
function toggleSound() {
const enabled = SoundManager.toggle();
document.getElementById('soundToggle').textContent = enabled ? '🔊' : '🔇';
}
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('soundToggle');
if (btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
});
</script>
{% include '_activity_sidebar.html' %}
</body>
+184 -62
View File
@@ -3,33 +3,123 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Лента активностей CS2 Simulator</title>
<title>Лента активностей - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.activity-feed {
max-width: 700px;
margin: 0 auto;
}
.activity-item {
display: flex;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 10px;
margin-bottom: 0.5rem;
align-items: flex-start;
transition: all 0.3s;
animation: fadeInUp 0.3s ease;
}
.activity-item:hover {
border-color: var(--primary-color);
}
.activity-icon {
font-size: 1.2rem;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-dark);
border-radius: 8px;
flex-shrink: 0;
}
.activity-content {
flex: 1;
min-width: 0;
}
.activity-message {
font-size: 0.9rem;
line-height: 1.4;
}
.activity-meta {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.activity-username {
color: var(--primary-color);
font-weight: 500;
}
.activity-empty {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
.activity-empty .big-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.activity-type-icon {
display: inline-block;
margin-right: 0.3rem;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.activity-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.live-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--success-color);
}
.live-dot {
width: 8px;
height: 8px;
background: var(--success-color);
border-radius: 50%;
animation: pulse 1.5s ease infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
</style>
</head>
<body>
<nav class="navbar">
<div class="container">
<a href="/" class="logo">CS2 <span>Simulator</span></a>
<button class="mobile-toggle" id="mobileToggle"></button>
<div class="nav-links" id="navLinks">
<a href="/" class="logo">🎮 CS2 Simulator</a>
<div class="nav-links">
{% if user %}
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</a>
<a href="/crash" class="nav-link">Crash</a>
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
<a href="/crash" class="nav-link">💥 Crash</a>
<a href="/inventory" class="nav-link">Инвентарь</a>
<a href="/activity" class="nav-link active">Лента</a>
<a href="/achievements" class="nav-link">Достижения</a>
<a href="/activity" class="nav-link active">📰 Лента</a>
<a href="/achievements" class="nav-link">🏆 Достижения</a>
<a href="/profile" class="nav-link">Профиль</a>
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-ghost">Выйти</button>
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-outline">Выйти</button>
{% else %}
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</a>
<a href="/activity" class="nav-link active">Лента</a>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
<a href="/activity" class="nav-link active">📰 Лента</a>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/login" class="btn btn-outline">Войти</a>
<a href="/register" class="btn btn-primary">Регистрация</a>
{% endif %}
@@ -37,48 +127,46 @@
</div>
</nav>
<main class="section">
<div class="container" style="max-width:700px;">
<div class="section-header">
<div>
<h1 class="section-title">📰 Лента активностей</h1>
<p class="section-subtitle">Что происходит на сервере</p>
</div>
<div class="flex gap-2 items-center" style="color:var(--success);font-size:0.85rem;">
<span style="width:8px;height:8px;border-radius:50%;background:var(--success);animation:pulse 1.5s ease infinite;"></span>
Live
</div>
<main class="container" style="padding: 2rem 1rem;">
<div class="activity-header">
<div>
<h1>📰 Лента активностей</h1>
<p class="page-subtitle">Что происходит на сервере</p>
</div>
<div class="live-indicator" id="liveIndicator">
<span class="live-dot"></span>
<span>Live</span>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:0.5rem;" id="activityFeed">
{% if activities %}
{% for a in activities %}
<div class="card" style="display:flex;gap:0.75rem;align-items:flex-start;animation:slideInUp 0.3s ease;" data-activity-id="{{ a.id }}">
<div style="font-size:1.1rem;width:36px;height:36px;display:flex;align-items:center;justify-content:center;background:var(--bg-dark);border-radius:var(--radius-sm);flex-shrink:0;">
{% if a.activity_type == 'case_open' %}📦
{% elif a.activity_type == 'contract' %}🔄
{% elif a.activity_type == 'upgrade' %}🆙
{% elif a.activity_type == 'achievement' %}🏆
{% elif a.activity_type == 'crash' %}💥
{% else %}📌{% endif %}
</div>
<div style="flex:1;min-width:0;">
<div class="text-sm">{{ a.message }}</div>
<div class="text-xs text-dim" style="margin-top:0.2rem;">
<a href="/profiles/{{ a.user_id }}" style="color:var(--primary);text-decoration:none;">{{ a.username }}</a>
<span>• {{ a.created_at }}</span>
</div>
<div class="activity-feed" id="activityFeed">
{% if activities %}
{% for a in activities %}
<div class="activity-item" data-activity-id="{{ a.id }}">
<div class="activity-icon">
{% if a.activity_type == 'case_open' %}📦
{% elif a.activity_type == 'contract' %}🔄
{% elif a.activity_type == 'upgrade' %}🆙
{% elif a.activity_type == 'achievement' %}🏆
{% elif a.activity_type == 'crash' %}💥
{% else %}📌{% endif %}
</div>
<div class="activity-content">
<div class="activity-message">{{ a.message }}</div>
<div class="activity-meta">
<a href="/profiles/{{ a.user_id }}" class="activity-username" style="text-decoration:none;">{{ a.username }}</a>
<span>• {{ a.created_at }}</span>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align:center;padding:3rem 1rem;color:var(--text-dim);">
<div style="font-size:2.5rem;margin-bottom:0.75rem;opacity:0.5;">📭</div>
<h3 style="font-family:var(--font-display);font-size:1.1rem;color:var(--text);">Пока нет активностей</h3>
<p style="font-size:0.9rem;">Открой кейс или выполни контракт — это появится здесь в реальном времени!</p>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="activity-empty">
<div class="big-icon">📭</div>
<h3>Пока нет активностей</h3>
<p>Открой кейс или выполни контракт — это появится здесь в реальном времени!</p>
</div>
{% endif %}
</div>
</main>
@@ -87,37 +175,71 @@
<script src="/static/js/safemode.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Подключаемся к WS и слушаем новые активности
WS.on('activity', (data) => {
const act = data.activity;
if (!act) return;
const feed = document.getElementById('activityFeed');
const empty = feed.querySelector('div[style*="text-align:center"]');
// Убираем empty state если есть
const empty = feed.querySelector('.activity-empty');
if (empty) empty.remove();
const iconMap = { 'case_open': '📦', 'contract': '🔄', 'upgrade': '🆙', 'achievement': '🏆', 'crash': '💥' };
const iconMap = {
'case_open': '📦',
'contract': '🔄',
'upgrade': '🆙',
'achievement': '🏆',
'crash': '💥'
};
const item = document.createElement('div');
item.className = 'card';
item.style.cssText = 'display:flex;gap:0.75rem;align-items:flex-start;animation:slideInUp 0.3s ease;';
item.className = 'activity-item';
item.style.animation = 'none';
item.offsetHeight;
item.style.animation = 'fadeInUp 0.3s ease';
item.innerHTML = `
<div style="font-size:1.1rem;width:36px;height:36px;display:flex;align-items:center;justify-content:center;background:var(--bg-dark);border-radius:var(--radius-sm);flex-shrink:0;">${iconMap[act.activity_type] || '📌'}</div>
<div style="flex:1;min-width:0;">
<div class="text-sm">${escapeHtml(act.message)}</div>
<div class="text-xs text-dim" style="margin-top:0.2rem;">
<a href="/profiles/${act.user_id || 0}" style="color:var(--primary);text-decoration:none;">${escapeHtml(act.username)}</a>
<div class="activity-icon">${iconMap[act.activity_type] || '📌'}</div>
<div class="activity-content">
<div class="activity-message">${act.message}</div>
<div class="activity-meta">
<a href="/profiles/${act.user_id || 0}" class="activity-username" style="text-decoration:none;">${escapeHtml(act.username)}</a>
<span>• только что</span>
</div>
</div>
`;
feed.insertBefore(item, feed.firstChild);
while (feed.children.length > 100) feed.removeChild(feed.lastChild);
if (act.activity_type === 'achievement') SoundManager.achievement();
// Ограничиваем до 100 элементов
while (feed.children.length > 100) {
feed.removeChild(feed.lastChild);
}
// Звук для новых активностей
if (act.activity_type === 'achievement') {
SoundManager.achievement();
} else if (act.activity_type === 'crash') {
SoundManager.crashCashout();
}
});
});
async function logout() {
await fetch('/web/api/auth/logout', { method: 'POST' });
window.location.href = '/';
}
</script>
<script>
function toggleSound() {
const enabled = SoundManager.toggle();
document.getElementById('soundToggle').textContent = enabled ? '🔊' : '🔇';
}
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('soundToggle');
if (btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
});
</script>
{% include '_activity_sidebar.html' %}
</body>
</html>
+335 -75
View File
@@ -6,93 +6,353 @@
<title>{% block title %}Админ-панель{% endblock %} — CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.admin-sidebar { width:240px; min-height:100vh; background:linear-gradient(180deg, #111216 0%, #0d0e12 100%); border-right:1px solid rgba(255,255,255,0.04); display:flex; flex-direction:column; position:sticky; top:0; height:100vh; flex-shrink:0; }
.admin-sidebar .sb-brand { padding:1.25rem 1.25rem 0.75rem; font-size:1rem; font-weight:700; letter-spacing:0.02em; display:flex; align-items:center; gap:0.5rem; border-bottom:1px solid rgba(255,255,255,0.04); }
.admin-sidebar .sb-brand span { color:#f59e0b; }
.admin-sidebar .sb-nav { flex:1; padding:0.75rem 0; display:flex; flex-direction:column; gap:0.15rem; }
.admin-sidebar .sb-nav a { display:flex; align-items:center; gap:0.65rem; padding:0.65rem 1.25rem; font-size:0.85rem; color:rgba(255,255,255,0.55); text-decoration:none; transition:all 0.15s; border-left:2px solid transparent; font-weight:450; }
.admin-sidebar .sb-nav a:hover { color:rgba(255,255,255,0.85); background:rgba(255,255,255,0.03); }
.admin-sidebar .sb-nav a.active { color:#f59e0b; background:rgba(245,158,11,0.06); border-left-color:#f59e0b; }
.admin-sidebar .sb-nav a .sb-icon { font-size:1rem; width:20px; text-align:center; }
.admin-sidebar .sb-footer { padding:0.75rem 1.25rem; border-top:1px solid rgba(255,255,255,0.04); font-size:0.75rem; color:rgba(255,255,255,0.3); }
.admin-sidebar .sb-footer a { color:rgba(255,255,255,0.5); text-decoration:none; display:flex; align-items:center; gap:0.4rem; }
.admin-sidebar .sb-footer a:hover { color:#f59e0b; }
.admin-main { flex:1; min-width:0; display:flex; flex-direction:column; }
.admin-topbar { display:flex; align-items:center; justify-content:space-between; padding:0.85rem 1.5rem; background:rgba(17,18,22,0.8); backdrop-filter:blur(8px); border-bottom:1px solid rgba(255,255,255,0.04); position:sticky; top:0; z-index:10; }
.admin-topbar .at-title { font-size:1rem; font-weight:600; display:flex; align-items:center; gap:0.5rem; }
.admin-topbar .at-title .at-sub { font-size:0.75rem; color:rgba(255,255,255,0.35); font-weight:400; }
.admin-topbar .at-actions { display:flex; align-items:center; gap:0.75rem; }
.admin-topbar .at-actions .at-back { color:rgba(255,255,255,0.5); text-decoration:none; font-size:0.8rem; display:flex; align-items:center; gap:0.3rem; transition:color 0.15s; }
.admin-topbar .at-actions .at-back:hover { color:#f59e0b; }
.admin-content { flex:1; padding:1.5rem; overflow-y:auto; }
.a-card { background:rgba(17,18,22,0.6); border:1px solid rgba(255,255,255,0.05); border-radius:12px; padding:1.25rem; margin-bottom:1.25rem; }
.a-card-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:1rem; font-size:0.85rem; font-weight:600; color:rgba(255,255,255,0.7); }
.a-stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:1rem; margin-bottom:1.5rem; }
.a-stat { background:rgba(17,18,22,0.6); border:1px solid rgba(255,255,255,0.05); border-radius:12px; padding:1.25rem; display:flex; flex-direction:column; gap:0.25rem; position:relative; overflow:hidden; }
.a-stat .as-label { font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em; color:rgba(255,255,255,0.35); }
.a-stat .as-value { font-size:1.8rem; font-weight:700; line-height:1.1; }
.a-stat .as-bar { position:absolute; bottom:0; left:0; height:2px; border-radius:0 2px 0 0; transition:width 0.6s ease; }
.a-stat .as-sub { font-size:0.75rem; color:rgba(255,255,255,0.35); }
.a-table-wrap { overflow-x:auto; border:1px solid rgba(255,255,255,0.05); border-radius:10px; }
.a-table { width:100%; border-collapse:collapse; font-size:0.82rem; }
.a-table th { text-align:left; padding:0.7rem 0.9rem; font-weight:600; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.05em; color:rgba(255,255,255,0.35); background:rgba(0,0,0,0.2); border-bottom:1px solid rgba(255,255,255,0.04); }
.a-table td { padding:0.65rem 0.9rem; border-bottom:1px solid rgba(255,255,255,0.03); vertical-align:middle; }
.a-table tr:last-child td { border-bottom:none; }
.a-table tr:hover td { background:rgba(255,255,255,0.02); }
.a-table .td-actions { display:flex; gap:0.35rem; flex-wrap:wrap; }
.a-btn { display:inline-flex; align-items:center; gap:0.35rem; padding:0.45rem 0.9rem; border:1px solid rgba(255,255,255,0.08); border-radius:7px; font-size:0.78rem; font-weight:500; cursor:pointer; transition:all 0.15s; background:rgba(255,255,255,0.04); color:rgba(255,255,255,0.7); text-decoration:none; line-height:1.3; white-space:nowrap; }
.a-btn:hover { background:rgba(255,255,255,0.08); border-color:rgba(255,255,255,0.15); color:#fff; }
.a-btn-primary { background:rgba(245,158,11,0.12); border-color:rgba(245,158,11,0.2); color:#f59e0b; }
.a-btn-primary:hover { background:rgba(245,158,11,0.2); border-color:#f59e0b; }
.a-btn-danger { background:rgba(239,68,68,0.1); border-color:rgba(239,68,68,0.15); color:#ef4444; }
.a-btn-danger:hover { background:rgba(239,68,68,0.2); border-color:#ef4444; }
.a-btn-success { background:rgba(34,197,94,0.1); border-color:rgba(34,197,94,0.15); color:#22c55e; }
.a-btn-success:hover { background:rgba(34,197,94,0.2); border-color:#22c55e; }
.a-btn-sm { padding:0.3rem 0.55rem; font-size:0.7rem; }
.a-btn:disabled { opacity:0.4; cursor:not-allowed; }
.a-input { background:rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.08); border-radius:7px; padding:0.5rem 0.75rem; color:#e8e8e8; font-size:0.82rem; outline:none; transition:border-color 0.15s; width:100%; }
.a-input:focus { border-color:rgba(245,158,11,0.4); }
.a-input-sm { padding:0.35rem 0.5rem; font-size:0.78rem; }
.a-label { display:block; font-size:0.75rem; font-weight:600; color:rgba(255,255,255,0.5); margin-bottom:0.35rem; }
.a-badge { display:inline-flex; padding:0.15rem 0.45rem; border-radius:4px; font-size:0.7rem; font-weight:600; }
.a-badge-admin { background:rgba(245,158,11,0.12); color:#f59e0b; }
.a-badge-user { background:rgba(59,130,246,0.12); color:#60a5fa; }
.a-badge-banned { background:rgba(239,68,68,0.12); color:#ef4444; }
.a-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); backdrop-filter:blur(4px); z-index:100; display:none; align-items:center; justify-content:center; padding:1rem; }
.a-overlay.open { display:flex; }
.a-modal { background:#16171d; border:1px solid rgba(255,255,255,0.06); border-radius:16px; padding:1.5rem; max-width:460px; width:100%; box-shadow:0 20px 60px rgba(0,0,0,0.5); }
.a-modal-title { font-size:1rem; font-weight:600; margin-bottom:1rem; }
.a-modal-actions { display:flex; gap:0.5rem; margin-top:1rem; justify-content:flex-end; }
.a-row { display:flex; gap:1rem; flex-wrap:wrap; }
.a-row > * { flex:1; min-width:0; }
.a-gap-sm { gap:0.5rem; }
.a-mt { margin-top:1rem; }
.a-mb { margin-bottom:1rem; }
.a-flex { display:flex; align-items:center; gap:0.5rem; }
.a-empty { text-align:center; padding:3rem 1rem; color:rgba(255,255,255,0.3); }
.a-empty .a-empty-icon { font-size:2.5rem; margin-bottom:0.5rem; }
@media(max-width:768px){ .admin-sidebar { width:56px; } .admin-sidebar .sb-brand span, .admin-sidebar .sb-nav a span:not(.sb-icon), .admin-sidebar .sb-footer span { display:none; } .admin-sidebar .sb-nav a { justify-content:center; padding:0.65rem; } .admin-sidebar .sb-footer a { justify-content:center; } }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 15px; }
body {
background: #0b0c10;
color: #e8e8e8;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
display: flex;
}
/* ─── Sidebar ─── */
.admin-sidebar {
width: 240px;
min-height: 100vh;
background: linear-gradient(180deg, #111216 0%, #0d0e12 100%);
border-right: 1px solid rgba(255,255,255,0.04);
display: flex;
flex-direction: column;
position: sticky; top: 0; height: 100vh;
flex-shrink: 0;
}
.admin-sidebar .sb-brand {
padding: 1.25rem 1.25rem 0.75rem;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.02em;
display: flex; align-items: center; gap: 0.5rem;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.admin-sidebar .sb-brand span { color: #f59e0b; }
.admin-sidebar .sb-nav {
flex: 1; padding: 0.75rem 0;
display: flex; flex-direction: column; gap: 0.15rem;
}
.admin-sidebar .sb-nav a {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.65rem 1.25rem;
font-size: 0.85rem;
color: rgba(255,255,255,0.55);
text-decoration: none;
transition: all 0.15s;
border-left: 2px solid transparent;
font-weight: 450;
}
.admin-sidebar .sb-nav a:hover {
color: rgba(255,255,255,0.85);
background: rgba(255,255,255,0.03);
}
.admin-sidebar .sb-nav a.active {
color: #f59e0b;
background: rgba(245,158,11,0.06);
border-left-color: #f59e0b;
}
.admin-sidebar .sb-nav a .sb-icon { font-size: 1rem; width: 20px; text-align: center; }
.admin-sidebar .sb-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid rgba(255,255,255,0.04);
font-size: 0.75rem;
color: rgba(255,255,255,0.3);
}
.admin-sidebar .sb-footer a {
color: rgba(255,255,255,0.5);
text-decoration: none;
display: flex; align-items: center; gap: 0.4rem;
}
.admin-sidebar .sb-footer a:hover { color: #f59e0b; }
/* ─── Main area ─── */
.admin-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.admin-topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 0.85rem 1.5rem;
background: rgba(17,18,22,0.8);
backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(255,255,255,0.04);
position: sticky; top: 0; z-index: 10;
}
.admin-topbar .at-title {
font-size: 1rem; font-weight: 600;
display: flex; align-items: center; gap: 0.5rem;
}
.admin-topbar .at-title .at-sub {
font-size: 0.75rem; color: rgba(255,255,255,0.35);
font-weight: 400;
}
.admin-topbar .at-actions { display: flex; align-items: center; gap: 0.75rem; }
.admin-topbar .at-actions .at-back {
color: rgba(255,255,255,0.5); text-decoration: none;
font-size: 0.8rem; display: flex; align-items: center; gap: 0.3rem;
transition: color 0.15s;
}
.admin-topbar .at-actions .at-back:hover { color: #f59e0b; }
.admin-topbar .at-actions .at-user {
color: rgba(255,255,255,0.5); font-size: 0.8rem;
display: flex; align-items: center; gap: 0.3rem;
}
.admin-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
/* ─── Cards ─── */
.a-card {
background: rgba(17,18,22,0.6);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
.a-card-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1rem;
font-size: 0.85rem; font-weight: 600;
color: rgba(255,255,255,0.7);
}
/* ─── Stats grid ─── */
.a-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.a-stat {
background: rgba(17,18,22,0.6);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 12px;
padding: 1.25rem;
display: flex; flex-direction: column; gap: 0.25rem;
position: relative; overflow: hidden;
}
.a-stat .as-label {
font-size: 0.7rem; text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255,255,255,0.35);
}
.a-stat .as-value {
font-size: 1.8rem; font-weight: 700;
line-height: 1.1;
}
.a-stat .as-bar {
position: absolute; bottom: 0; left: 0; height: 2px;
border-radius: 0 2px 0 0;
transition: width 0.6s ease;
}
.a-stat .as-sub {
font-size: 0.75rem; color: rgba(255,255,255,0.35);
}
/* ─── Tables ─── */
.a-table-wrap {
overflow-x: auto;
border: 1px solid rgba(255,255,255,0.05);
border-radius: 10px;
}
.a-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.a-table th {
text-align: left;
padding: 0.7rem 0.9rem;
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255,255,255,0.35);
background: rgba(0,0,0,0.2);
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.a-table td {
padding: 0.65rem 0.9rem;
border-bottom: 1px solid rgba(255,255,255,0.03);
vertical-align: middle;
}
.a-table tr:last-child td { border-bottom: none; }
.a-table tr:hover td { background: rgba(255,255,255,0.02); }
.a-table .td-actions {
display: flex; gap: 0.35rem;
flex-wrap: wrap;
}
/* ─── Buttons ─── */
.a-btn {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.45rem 0.9rem;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 7px;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
background: rgba(255,255,255,0.04);
color: rgba(255,255,255,0.7);
text-decoration: none;
line-height: 1.3;
white-space: nowrap;
}
.a-btn:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.15); color: #fff; }
.a-btn-primary {
background: rgba(245,158,11,0.12);
border-color: rgba(245,158,11,0.2);
color: #f59e0b;
}
.a-btn-primary:hover { background: rgba(245,158,11,0.2); border-color: #f59e0b; }
.a-btn-danger {
background: rgba(239,68,68,0.1);
border-color: rgba(239,68,68,0.15);
color: #ef4444;
}
.a-btn-danger:hover { background: rgba(239,68,68,0.2); border-color: #ef4444; }
.a-btn-success {
background: rgba(34,197,94,0.1);
border-color: rgba(34,197,94,0.15);
color: #22c55e;
}
.a-btn-success:hover { background: rgba(34,197,94,0.2); border-color: #22c55e; }
.a-btn-sm { padding: 0.3rem 0.55rem; font-size: 0.7rem; }
.a-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ─── Form inputs ─── */
.a-input {
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 7px;
padding: 0.5rem 0.75rem;
color: #e8e8e8;
font-size: 0.82rem;
outline: none;
transition: border-color 0.15s;
width: 100%;
}
.a-input:focus { border-color: rgba(245,158,11,0.4); }
.a-input-sm { padding: 0.35rem 0.5rem; font-size: 0.78rem; }
.a-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: rgba(255,255,255,0.5);
margin-bottom: 0.35rem;
}
/* ─── Badge ─── */
.a-badge {
display: inline-flex;
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
}
.a-badge-admin { background: rgba(245,158,11,0.12); color: #f59e0b; }
.a-badge-user { background: rgba(59,130,246,0.12); color: #60a5fa; }
.a-badge-banned { background: rgba(239,68,68,0.12); color: #ef4444; }
/* ─── Modal ─── */
.a-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
z-index: 100; display: none; align-items: center; justify-content: center;
padding: 1rem;
}
.a-overlay.open { display: flex; }
.a-modal {
background: #16171d;
border: 1px solid rgba(255,255,255,0.06);
border-radius: 16px;
padding: 1.5rem;
max-width: 460px; width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.a-modal-title {
font-size: 1rem; font-weight: 600; margin-bottom: 1rem;
}
.a-modal-actions {
display: flex; gap: 0.5rem; margin-top: 1rem;
justify-content: flex-end;
}
/* ─── Grid helpers ─── */
.a-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.a-row > * { flex: 1; min-width: 0; }
.a-gap-sm { gap: 0.5rem; }
.a-mt { margin-top: 1rem; }
.a-mb { margin-bottom: 1rem; }
.a-flex { display: flex; align-items: center; gap: 0.5rem; }
/* ─── Empty state ─── */
.a-empty {
text-align: center; padding: 3rem 1rem;
color: rgba(255,255,255,0.3);
}
.a-empty .a-empty-icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
@media(max-width:768px){
.admin-sidebar { width: 56px; }
.admin-sidebar .sb-brand span,
.admin-sidebar .sb-nav a span:not(.sb-icon),
.admin-sidebar .sb-footer span { display: none; }
.admin-sidebar .sb-nav a { justify-content: center; padding: 0.65rem; }
.admin-sidebar .sb-footer a { justify-content: center; }
}
{% block extra_styles %}{% endblock %}
</style>
</head>
<body style="display:flex;background:#0b0c10;color:#e8e8e8;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;min-height:100vh;">
<body>
<div class="admin-sidebar">
<div class="sb-brand"><span style="font-size:1.2rem"></span><span>CS2 <span>Admin</span></span></div>
<div class="sb-brand">
<span style="font-size:1.2rem"></span>
<span>CS2 <span>Admin</span></span>
</div>
<nav class="sb-nav">
<a href="/admin" class="{% block nav_dashboard %}{% endblock %}"><span class="sb-icon">📊</span> <span>Дашборд</span></a>
<a href="/admin/users" class="{% block nav_users %}{% endblock %}"><span class="sb-icon">👥</span> <span>Пользователи</span></a>
<a href="/admin/cases" class="{% block nav_cases %}{% endblock %}"><span class="sb-icon">📦</span> <span>Кейсы</span></a>
<a href="/admin" class="{% block nav_dashboard %}{% endblock %}">
<span class="sb-icon">📊</span> <span>Дашборд</span>
</a>
<a href="/admin/users" class="{% block nav_users %}{% endblock %}">
<span class="sb-icon">👥</span> <span>Пользователи</span>
</a>
<a href="/admin/cases" class="{% block nav_cases %}{% endblock %}">
<span class="sb-icon">📦</span> <span>Кейсы</span>
</a>
</nav>
<div class="sb-footer"><a href="/">&larr; <span>На сайт</span></a></div>
<div class="sb-footer">
<a href="/">&larr; <span>На сайт</span></a>
</div>
</div>
<div class="admin-main">
<div class="admin-topbar">
<div class="at-title">{% block page_title %}Панель управления{% endblock %}</div>
<div class="at-actions">{% block top_actions %}{% endblock %}</div>
<div class="at-title">
{% block page_title %}Панель управления{% endblock %}
</div>
<div class="at-actions">
{% block top_actions %}{% endblock %}
</div>
</div>
<div class="admin-content">
{% block content %}{% endblock %}
</div>
<div class="admin-content">{% block content %}{% endblock %}</div>
</div>
{% block modals %}{% endblock %}
+204 -42
View File
@@ -2,7 +2,9 @@
{% block title %}Кейсы — Админ-панель{% endblock %}
{% block nav_cases %}active{% endblock %}
{% block page_title %}📦 Управление кейсами{% endblock %}
{% block top_actions %}<button class="a-btn a-btn-primary" onclick="openCreateCaseModal()"> Создать кейс</button>{% endblock %}
{% block top_actions %}
<button class="a-btn a-btn-primary" onclick="openCreateCaseModal()"> Создать кейс</button>
{% endblock %}
{% block content %}
<div class="a-card">
@@ -62,14 +64,29 @@
<div class="a-modal-title" id="caseModalTitle">🎲 Создать кейс</div>
<input type="hidden" id="editCaseName">
<div class="a-row a-gap-sm">
<div class="a-mb" style="flex:1"><label class="a-label">Название кейса (ID)</label><input type="text" id="caseNameInput" class="a-input" placeholder="my_case"></div>
<div class="a-mb" style="flex:1"><label class="a-label">Отображаемое название</label><input type="text" id="caseDisplayName" class="a-input" placeholder="Мой кейс"></div>
<div class="a-mb" style="flex:1">
<label class="a-label">Название кейса (ID)</label>
<input type="text" id="caseNameInput" class="a-input" placeholder="my_case">
</div>
<div class="a-mb" style="flex:1">
<label class="a-label">Отображаемое название</label>
<input type="text" id="caseDisplayName" class="a-input" placeholder="Мой кейс">
</div>
</div>
<div class="a-row a-gap-sm">
<div class="a-mb" style="flex:1"><label class="a-label">Цена открытия</label><input type="number" id="casePrice" class="a-input" value="250"></div>
<div class="a-mb" style="flex:1"><label class="a-label">Ссылка на изображение</label><input type="text" id="caseImage" class="a-input" placeholder="/static/cases/default.png"></div>
<div class="a-mb" style="flex:1">
<label class="a-label">Цена открытия</label>
<input type="number" id="casePrice" class="a-input" value="250">
</div>
<div class="a-mb" style="flex:1">
<label class="a-label">Ссылка на изображение</label>
<input type="text" id="caseImage" class="a-input" placeholder="/static/cases/default.png">
</div>
</div>
<div class="a-mb">
<label class="a-label">Описание (необязательно)</label>
<input type="text" id="caseDescription" class="a-input" placeholder="Описание кейса...">
</div>
<div class="a-mb"><label class="a-label">Описание (необязательно)</label><input type="text" id="caseDescription" class="a-input" placeholder="Описание кейса..."></div>
<div class="a-card" style="background:rgba(0,0,0,0.2);padding:0.75rem">
<div class="a-card-header" style="margin-bottom:0.5rem">📋 Предметы кейса</div>
<div id="caseItemsList"></div>
@@ -99,8 +116,14 @@
<div class="a-modal" style="max-width:400px">
<div class="a-modal-title">➕ Добавить кейс в секцию</div>
<p style="font-size:0.82rem;color:rgba(255,255,255,0.5);margin-bottom:0.75rem">Секция: <strong id="addCaseSectionName"></strong></p>
<div class="a-mb"><label class="a-label">Выберите кейс</label><select id="addCaseSelect" class="a-input"></select></div>
<div class="a-modal-actions"><button class="a-btn" onclick="closeModal('addCaseModal')">Отмена</button><button class="a-btn a-btn-primary" onclick="confirmAddCase()">Добавить</button></div>
<div class="a-mb">
<label class="a-label">Выберите кейс</label>
<select id="addCaseSelect" class="a-input"></select>
</div>
<div class="a-modal-actions">
<button class="a-btn" onclick="closeModal('addCaseModal')">Отмена</button>
<button class="a-btn a-btn-primary" onclick="confirmAddCase()">Добавить</button>
</div>
</div>
</div>
@@ -108,8 +131,14 @@
<div class="a-overlay" id="addSectionModal">
<div class="a-modal" style="max-width:400px">
<div class="a-modal-title"> Новая секция</div>
<div class="a-mb"><label class="a-label">Название вкладки</label><input type="text" id="newSectionName" class="a-input" placeholder="Новая коллекция"></div>
<div class="a-modal-actions"><button class="a-btn" onclick="closeModal('addSectionModal')">Отмена</button><button class="a-btn a-btn-primary" onclick="confirmAddSection()">Создать</button></div>
<div class="a-mb">
<label class="a-label">Название вкладки</label>
<input type="text" id="newSectionName" class="a-input" placeholder="Новая коллекция">
</div>
<div class="a-modal-actions">
<button class="a-btn" onclick="closeModal('addSectionModal')">Отмена</button>
<button class="a-btn a-btn-primary" onclick="confirmAddSection()">Создать</button>
</div>
</div>
</div>
{% endblock %}
@@ -128,44 +157,98 @@
{% block scripts %}
<script>
let currentCaseItems = [], editingCaseName = null;
let currentCaseItems = [];
let editingCaseName = null;
// ─── Modals ──────────────────────────────────────────────────────────────────
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
function openModal(id) { document.getElementById(id).classList.add('open'); }
// ─── Load page data ──────────────────────────────────────────────────────────
let allCasesList = [];
document.addEventListener('DOMContentLoaded', async () => { const r = await fetch('/admin/api/cases/list'); const d = await r.json(); allCasesList = d.cases||[]; });
document.addEventListener('DOMContentLoaded', async () => {
const r = await fetch('/admin/api/cases/list');
const d = await r.json();
allCasesList = d.cases || [];
});
// ─── Create / Edit Case ──────────────────────────────────────────────────────
function openCreateCaseModal() {
editingCaseName = null; document.getElementById('caseModalTitle').textContent = '🎲 Создать кейс'; document.getElementById('saveCaseBtn').textContent = '💾 Создать';
document.getElementById('editCaseName').value = ''; document.getElementById('caseNameInput').value = ''; document.getElementById('caseDisplayName').value = '';
document.getElementById('casePrice').value = '250'; document.getElementById('caseImage').value = ''; document.getElementById('caseDescription').value = '';
currentCaseItems = []; renderCaseItems(); openModal('caseModal');
editingCaseName = null;
document.getElementById('caseModalTitle').textContent = '🎲 Создать кейс';
document.getElementById('saveCaseBtn').textContent = '💾 Создать';
document.getElementById('editCaseName').value = '';
document.getElementById('caseNameInput').value = '';
document.getElementById('caseDisplayName').value = '';
document.getElementById('casePrice').value = '250';
document.getElementById('caseImage').value = '';
document.getElementById('caseDescription').value = '';
currentCaseItems = [];
renderCaseItems();
openModal('caseModal');
}
function openEditCaseModal(caseName) {
editingCaseName = caseName; document.getElementById('caseModalTitle').textContent = '✏️ Редактировать кейс'; document.getElementById('saveCaseBtn').textContent = '💾 Сохранить';
editingCaseName = caseName;
document.getElementById('caseModalTitle').textContent = '✏️ Редактировать кейс';
document.getElementById('saveCaseBtn').textContent = '💾 Сохранить';
document.getElementById('editCaseName').value = caseName;
const cfg = {{ cases_config|tojson }}, c = cfg[caseName]||{};
document.getElementById('caseNameInput').value = caseName; document.getElementById('caseDisplayName').value = c.display_name||'';
document.getElementById('casePrice').value = c.price_open||250; document.getElementById('caseImage').value = c.image_url||''; document.getElementById('caseDescription').value = c.description||'';
currentCaseItems = (c.items||[]).filter(i => typeof i === 'object'); renderCaseItems(); openModal('caseModal');
const cfg = {{ cases_config|tojson }};
const c = cfg[caseName] || {};
document.getElementById('caseNameInput').value = caseName;
document.getElementById('caseDisplayName').value = c.display_name || '';
document.getElementById('casePrice').value = c.price_open || 250;
document.getElementById('caseImage').value = c.image_url || '';
document.getElementById('caseDescription').value = c.description || '';
currentCaseItems = (c.items || []).filter(i => typeof i === 'object');
renderCaseItems();
openModal('caseModal');
}
function renderCaseItems() { document.getElementById('caseItemsList').innerHTML = currentCaseItems.map((item,i) => `<div class="ci-item"><img src="${item.image_url||'/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'"><div class="ci-info"><div>${item.market_hash_name||item.name||'???'}</div><div class="ci-rarity">${item.rarity||''} • ${item.weapon||''}</div></div><button class="a-btn a-btn-danger a-btn-sm" onclick="removeCaseItem(${i})"></button></div>`).join(''); }
function renderCaseItems() {
const div = document.getElementById('caseItemsList');
div.innerHTML = currentCaseItems.map((item, i) => `
<div class="ci-item">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="ci-info">
<div>${item.market_hash_name || item.name || '???'}</div>
<div class="ci-rarity">${item.rarity || ''} • ${item.weapon || ''}</div>
</div>
<button class="a-btn a-btn-danger a-btn-sm" onclick="removeCaseItem(${i})"></button>
</div>
`).join('');
}
function removeCaseItem(idx) { currentCaseItems.splice(idx,1); renderCaseItems(); }
function addCaseItem(item) { if (currentCaseItems.some(i => (i.market_hash_name||i.name) === (item.market_hash_name||item.name))) return; currentCaseItems.push(item); renderCaseItems(); }
function removeCaseItem(idx) {
currentCaseItems.splice(idx, 1);
renderCaseItems();
}
function addCaseItem(item) {
if (currentCaseItems.some(i => (i.market_hash_name || i.name) === (item.market_hash_name || item.name))) return;
currentCaseItems.push(item);
renderCaseItems();
}
async function searchCaseItems() {
const q = document.getElementById('caseItemSearch').value;
if (q.length < 2) return;
const r = await fetch(`/admin/api/items/search?q=${encodeURIComponent(q)}`);
const items = await r.json();
// show as dropdown
let html = '<div style="margin-top:0.5rem;max-height:200px;overflow-y:auto">';
items.forEach(item => { html += `<div class="ci-item" style="cursor:pointer" onclick="addCaseItem(${JSON.stringify(item).replace(/"/g,'&quot;')})"><img src="${item.image_url||'/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'"><div class="ci-info"><div>${item.name}</div><div class="ci-rarity">${item.rarity} • ${item.price_rub||0} ₽</div></div></div>`; });
items.forEach(item => {
html += `<div class="ci-item" style="cursor:pointer" onclick="addCaseItem(${JSON.stringify(item).replace(/"/g,'&quot;')})">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="ci-info"><div>${item.name}</div><div class="ci-rarity">${item.rarity} • ${item.price_rub||0} ₽</div></div>
</div>`;
});
html += '</div>';
let existing = document.getElementById('caseItemSearchResults');
if (!existing) { const d = document.createElement('div'); d.id = 'caseItemSearchResults'; document.getElementById('caseItemSearch').parentNode.appendChild(d); }
const existing = document.getElementById('caseItemSearchResults');
if (!existing) {
const d = document.createElement('div'); d.id = 'caseItemSearchResults';
document.getElementById('caseItemSearch').parentNode.appendChild(d);
}
document.getElementById('caseItemSearchResults').innerHTML = html;
}
@@ -173,27 +256,106 @@ async function saveCase() {
const name = document.getElementById('caseNameInput').value.trim();
if (!name) { alert('Введите название кейса'); return; }
const fd = new FormData();
fd.append('case_name', name); fd.append('display_name', document.getElementById('caseDisplayName').value);
fd.append('price_open', document.getElementById('casePrice').value); fd.append('image_url', document.getElementById('caseImage').value);
fd.append('description', document.getElementById('caseDescription').value); fd.append('items_json', JSON.stringify(currentCaseItems));
fd.append('case_name', name);
fd.append('display_name', document.getElementById('caseDisplayName').value);
fd.append('price_open', document.getElementById('casePrice').value);
fd.append('image_url', document.getElementById('caseImage').value);
fd.append('description', document.getElementById('caseDescription').value);
fd.append('items_json', JSON.stringify(currentCaseItems));
const isEdit = editingCaseName !== null && editingCaseName !== name;
const r = await fetch(isEdit ? `/admin/api/cases/update/${encodeURIComponent(editingCaseName)}` : '/admin/api/cases/create', { method:'POST', body:fd });
const url = isEdit
? `/admin/api/cases/update/${encodeURIComponent(editingCaseName)}`
: '/admin/api/cases/create';
const r = await fetch(url, { method:'POST', body:fd });
const d = await r.json();
if (d.success) window.location.reload(); else alert(d.error||'Ошибка');
if (d.success) { window.location.reload(); }
else alert(d.error || 'Ошибка');
}
async function deleteCase(name) { if (!confirm(`Удалить кейс "${name}"?`)) return; const fd = new FormData(); fd.append('case_name', name); const r = await fetch(`/admin/api/cases/delete/${encodeURIComponent(name)}`, { method:'POST', body:fd }); const d = await r.json(); if (d.success) window.location.reload(); else alert(d.error); }
async function deleteCase(name) {
if (!confirm(`Удалить кейс "${name}"?`)) return;
const fd = new FormData(); fd.append('case_name', name);
const r = await fetch(`/admin/api/cases/delete/${encodeURIComponent(name)}`, { method:'POST', body:fd });
const d = await r.json();
if (d.success) window.location.reload();
else alert(d.error);
}
async function openAllItemsModal() { openModal('allItemsModal'); document.getElementById('allItemsSearch').value = ''; await searchAllItems(); }
async function searchAllItems() { const q = document.getElementById('allItemsSearch').value; const r = await fetch(`/admin/api/items/search?q=${encodeURIComponent(q)}&limit=200`); const items = await r.json(); document.getElementById('allItemsGrid').innerHTML = items.map(item => `<div class="all-item-card" onclick="addCaseItemFromAll(${JSON.stringify(item).replace(/"/g,'&quot;')})"><img src="${item.image_url||'/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'"><div style="margin-top:0.2rem;line-height:1.15">${item.name}</div><div style="font-size:0.6rem;color:rgba(255,255,255,0.35)">${item.rarity}</div></div>`).join(''); }
function addCaseItemFromAll(item) { addCaseItem(item); closeModal('allItemsModal'); }
// ─── All Items Browser ───────────────────────────────────────────────────────
async function openAllItemsModal() {
openModal('allItemsModal');
document.getElementById('allItemsSearch').value = '';
await searchAllItems();
}
async function searchAllItems() {
const q = document.getElementById('allItemsSearch').value;
const r = await fetch(`/admin/api/items/search?q=${encodeURIComponent(q)}&limit=200`);
const items = await r.json();
document.getElementById('allItemsGrid').innerHTML = items.map(item => `
<div class="all-item-card" onclick="addCaseItemFromAll(${JSON.stringify(item).replace(/"/g,'&quot;')})">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div style="margin-top:0.2rem;line-height:1.15">${item.name}</div>
<div style="font-size:0.6rem;color:rgba(255,255,255,0.35)">${item.rarity}</div>
</div>
`).join('');
}
function addCaseItemFromAll(item) {
addCaseItem(item);
closeModal('allItemsModal');
}
function openAddSectionModal() { document.getElementById('newSectionName').value = ''; openModal('addSectionModal'); }
async function confirmAddSection() { const name = document.getElementById('newSectionName').value.trim(); if (!name) return; const fd = new FormData(); fd.append('tab', name); const r = await fetch('/admin/api/mainpage/section/add', { method:'POST', body:fd }); const d = await r.json(); if (d.success) window.location.reload(); else alert(d.error); }
async function deleteSection(tab) { if (!confirm(`Удалить секцию "${tab}"?`)) return; const fd = new FormData(); fd.append('tab', tab); const r = await fetch('/admin/api/mainpage/section/delete', { method:'POST', body:fd }); const d = await r.json(); if (d.success) window.location.reload(); else alert(d.error); }
// ─── Mainpage Sections ───────────────────────────────────────────────────────
function openAddSectionModal() {
document.getElementById('newSectionName').value = '';
openModal('addSectionModal');
}
async function confirmAddSection() {
const name = document.getElementById('newSectionName').value.trim();
if (!name) return;
const fd = new FormData(); fd.append('tab', name);
const r = await fetch('/admin/api/mainpage/section/add', { method:'POST', body:fd });
const d = await r.json();
if (d.success) window.location.reload();
else alert(d.error);
}
async function deleteSection(tab) {
if (!confirm(`Удалить секцию "${tab}"?`)) return;
const fd = new FormData(); fd.append('tab', tab);
const r = await fetch('/admin/api/mainpage/section/delete', { method:'POST', body:fd });
const d = await r.json();
if (d.success) window.location.reload();
else alert(d.error);
}
function openAddCaseModal(tab) { document.getElementById('addCaseSectionName').textContent = tab; const sel = document.getElementById('addCaseSelect'); sel.innerHTML = allCasesList.map(c => `<option value="${c}">${c}</option>`).join(''); sel.dataset.tab = tab; openModal('addCaseModal'); }
async function confirmAddCase() { const tab = document.getElementById('addCaseSelect').dataset.tab, caseName = document.getElementById('addCaseSelect').value; const fd = new FormData(); fd.append('tab', tab); fd.append('case_name', caseName); const r = await fetch('/admin/api/mainpage/section/add-case', { method:'POST', body:fd }); const d = await r.json(); if (d.success) window.location.reload(); else alert(d.error); }
async function removeCaseFromSection(tab, caseName) { if (!confirm(`Убрать "${caseName}" из "${tab}"?`)) return; const fd = new FormData(); fd.append('tab', tab); fd.append('case_name', caseName); const r = await fetch('/admin/api/mainpage/section/remove-case', { method:'POST', body:fd }); const d = await r.json(); if (d.success) window.location.reload(); else alert(d.error); }
function openAddCaseModal(tab) {
document.getElementById('addCaseSectionName').textContent = tab;
const sel = document.getElementById('addCaseSelect');
sel.innerHTML = allCasesList.map(c => `<option value="${c}">${c}</option>`).join('');
sel.dataset.tab = tab;
openModal('addCaseModal');
}
async function confirmAddCase() {
const tab = document.getElementById('addCaseSelect').dataset.tab;
const caseName = document.getElementById('addCaseSelect').value;
const fd = new FormData();
fd.append('tab', tab);
fd.append('case_name', caseName);
const r = await fetch('/admin/api/mainpage/section/add-case', { method:'POST', body:fd });
const d = await r.json();
if (d.success) window.location.reload();
else alert(d.error);
}
async function removeCaseFromSection(tab, caseName) {
if (!confirm(`Убрать "${caseName}" из "${tab}"?`)) return;
const fd = new FormData();
fd.append('tab', tab);
fd.append('case_name', caseName);
const r = await fetch('/admin/api/mainpage/section/remove-case', { method:'POST', body:fd });
const d = await r.json();
if (d.success) window.location.reload();
else alert(d.error);
}
</script>
{% endblock %}
+3 -3
View File
@@ -44,9 +44,9 @@
<div class="a-row a-gap-sm a-mb" style="text-align:center;justify-content:space-around">
<div><div class="as-value" style="font-size:1.2rem">{{ rpu_count }}</div><div class="as-label" style="font-size:0.7rem">Всего записей</div></div>
<div><div class="as-value" style="font-size:1.2rem">{{ "%.1f"|format(avg_rpu) }}%</div><div class="as-label" style="font-size:0.7rem">Средний уровень</div></div>
<div><div class="as-value" style="font-size:1.2rem;color:#22c55e">{{ lucky_count }}</div><div class="as-label" style="font-size:0.7rem">🍀 Везучих</div></div>
<div><div class="as-value" style="font-size:1.2rem;color:#f59e0b">{{ normal_count }}</div><div class="as-label" style="font-size:0.7rem">📊 Обычных</div></div>
<div><div class="as-value" style="font-size:1.2rem;color:#ef4444">{{ unlucky_count }}</div><div class="as-label" style="font-size:0.7rem">🌧️ Невезучих</div></div>
<div><div class="as-value" style="font-size:1.2rem" style="color:#22c55e">{{ lucky_count }}</div><div class="as-label" style="font-size:0.7rem">🍀 Везучих</div></div>
<div><div class="as-value" style="font-size:1.2rem" style="color:#f59e0b">{{ normal_count }}</div><div class="as-label" style="font-size:0.7rem">📊 Обычных</div></div>
<div><div class="as-value" style="font-size:1.2rem" style="color:#ef4444">{{ unlucky_count }}</div><div class="as-label" style="font-size:0.7rem">🌧️ Невезучих</div></div>
<div><div class="as-value" style="font-size:1.2rem">{{ "%.0f"|format(total_spent) }} ₽</div><div class="as-label" style="font-size:0.7rem">Потрачено всего</div></div>
</div>
{% else %}
+47 -40
View File
@@ -2,7 +2,7 @@
{% block title %}{{ target_user.username }} — Админ-панель{% endblock %}
{% block nav_users %}active{% endblock %}
{% block page_title %}
{{ target_user.username }}
👤 {{ target_user.username }}
{% if target_user.is_admin %}<span class="a-badge a-badge-admin" style="margin-left:0.5rem">Админ</span>{% endif %}
{% if target_user.is_banned %}<span class="a-badge a-badge-banned" style="margin-left:0.5rem">Забанен</span>{% endif %}
{% endblock %}
@@ -11,63 +11,62 @@
{% endblock %}
{% block content %}
<div class="a-stats">
<div class="a-stats" style="grid-template-columns:repeat(4,1fr)">
<div class="a-stat">
<div class="as-label">Баланс</div>
<div class="as-value">{{ "%.0f"|format(target_user.balance) }} ₽</div>
<div class="as-bar" style="width:60%;background:#f59e0b"></div>
</div>
<div class="a-stat">
<div class="as-label">Предметов</div>
<div class="as-value">{{ inventory|length }}</div>
<div class="as-bar" style="width:45%;background:#60a5fa"></div>
</div>
<div class="a-stat">
<div class="as-label">Открытий кейсов</div>
<div class="as-value">{{ total_openings }}</div>
<div class="as-bar" style="width:30%;background:#a78bfa"></div>
</div>
<div class="a-stat">
<div class="as-label">Контрактов</div>
<div class="as-value">{{ total_contracts }}</div>
<div class="as-bar" style="width:20%;background:#f472b6"></div>
</div>
</div>
<!-- RPU -->
<div class="a-card">
<div class="a-card-header">RPU (Режим Персонального Угнетения)</div>
<div class="a-row a-gap-sm a-mb" style="text-align:center">
<div><div class="as-value" style="font-size:1.2rem" id="rpuLevel">--</div><div class="as-label" style="font-size:0.7rem">Уровень</div></div>
<div><div class="as-value" style="font-size:1.2rem" id="rpuLuck">--</div><div class="as-label" style="font-size:0.7rem">Множитель</div></div>
<div><div class="as-value" style="font-size:1.2rem" id="rpuSpent">--</div><div class="as-label" style="font-size:0.7rem">Потрачено</div></div>
<div><div class="as-value" style="font-size:1.2rem" id="rpuOpened">--</div><div class="as-label" style="font-size:0.7rem">Открыто</div></div>
<div><div class="as-value" style="font-size:1.2rem" id="rpuAutoStatus">--</div><div class="as-label" style="font-size:0.7rem">Авто</div></div>
<div class="a-card-header">🎲 РПУ (Режим Персонального Угнетения)</div>
<div class="a-row a-gap-sm a-mb" id="rpuStats" style="justify-content:space-around;text-align:center">
<div><div class="as-value" style="font-size:1.4rem" id="rpuLevel">--</div><div class="as-label" style="font-size:0.7rem">Уровень</div></div>
<div><div class="as-value" style="font-size:1.4rem" id="rpuLuck">--</div><div class="as-label" style="font-size:0.7rem">Множитель</div></div>
<div><div class="as-value" style="font-size:1.4rem" id="rpuSpent">--</div><div class="as-label" style="font-size:0.7rem">Потрачено</div></div>
<div><div class="as-value" style="font-size:1.4rem" id="rpuOpened">--</div><div class="as-label" style="font-size:0.7rem">Открыто</div></div>
<div><div class="as-value" style="font-size:1.4rem" id="rpuAutoStatus">--</div><div class="as-label" style="font-size:0.7rem">Авто</div></div>
</div>
<div class="td-actions" style="flex-wrap:wrap">
<button class="a-btn a-btn-primary" onclick="openRPUModal()">Настроить</button>
<button class="a-btn" style="border-color:#10b981;color:#10b981" onclick="setRPUPreset('lucky')">Везучий</button>
<button class="a-btn" style="border-color:#f59e0b;color:#f59e0b" onclick="setRPUPreset('normal')">Обычный</button>
<button class="a-btn" style="border-color:#ef4444;color:#ef4444" onclick="setRPUPreset('unlucky')">Невезучий</button>
<button class="a-btn a-btn-danger" onclick="setRPUPreset('very_unlucky')">Проклятый</button>
<div class="a-flex" style="flex-wrap:wrap">
<button class="a-btn a-btn-primary" onclick="openRPUModal()">⚙️ Настроить</button>
<button class="a-btn" style="border-color:#10b981;color:#10b981" onclick="setRPUPreset('lucky')">🍀 Везучий</button>
<button class="a-btn" style="border-color:#f59e0b;color:#f59e0b" onclick="setRPUPreset('normal')">📊 Обычный</button>
<button class="a-btn" style="border-color:#ef4444;color:#ef4444" onclick="setRPUPreset('unlucky')">🌧️ Невезучий</button>
<button class="a-btn a-btn-danger" onclick="setRPUPreset('very_unlucky')">💀 Проклятый</button>
</div>
</div>
<!-- Действия -->
<div class="a-card">
<div class="a-card-header">Действия</div>
<div class="td-actions" style="flex-wrap:wrap">
<button class="a-btn a-btn-primary" onclick="openBalanceModal()">Изменить баланс</button>
<button class="a-btn" onclick="openGiveItemModal()">Выдать предмет</button>
<div class="a-card-header">🔧 Действия</div>
<div class="a-flex" style="flex-wrap:wrap">
<button class="a-btn a-btn-primary" onclick="openBalanceModal()">💰 Баланс</button>
<button class="a-btn" onclick="openGiveItemModal()">🎁 Выдать предмет</button>
{% if target_user.id != user.id %}
<button class="a-btn" onclick="toggleAdmin()">{{ "Снять админа" if target_user.is_admin else "Сделать админом" }}</button>
<button class="a-btn" onclick="toggleAdmin()">{{ "👑 Снять админа" if target_user.is_admin else "👑 Сделать админом" }}</button>
{% endif %}
<button class="a-btn {{ 'a-btn-danger' if not target_user.is_banned else 'a-btn-success' }}" onclick="toggleBan()">
{{ "Забанить" if not target_user.is_banned else "Разбанить" }}
{{ "🔨 Забанить" if not target_user.is_banned else "Разбанить" }}
</button>
</div>
</div>
<!-- Инвентарь -->
<div class="a-card">
<div class="a-card-header">Инвентарь (последние 50)</div>
<div class="a-card-header">🎒 Инвентарь (последние 50)</div>
<div class="a-table-wrap">
<table class="a-table">
<thead><tr><th>ID</th><th>Название</th><th>Редкость</th><th>Float</th><th></th></tr></thead>
@@ -79,7 +78,7 @@
<td>{{ item.rarity }}</td>
<td>{{ "%.4f"|format(item.float_value) }}</td>
<td class="td-actions">
<button class="a-btn a-btn-danger a-btn-sm" onclick="deleteItem({{ item.id }})">Удалить</button>
<button class="a-btn a-btn-danger a-btn-sm" onclick="deleteItem({{ item.id }})">🗑️</button>
</td>
</tr>
{% endfor %}
@@ -90,10 +89,11 @@
{% endblock %}
{% block modals %}
<!-- Баланс -->
<div class="a-overlay" id="balanceModal">
<div class="a-modal">
<div class="a-modal-title">Изменить баланс</div>
<p class="a-mb" style="font-size:0.85rem;color:rgba(255,255,255,0.5)">
<div class="a-modal-title">💰 Изменить баланс</div>
<p style="font-size:0.85rem;color:rgba(255,255,255,0.5);margin-bottom:1rem">
Текущий: <strong style="color:#f59e0b">{{ "%.2f"|format(target_user.balance) }} ₽</strong>
</p>
<div class="a-mb">
@@ -103,9 +103,9 @@
<div class="a-mb">
<label class="a-label">Операция</label>
<select id="balanceOperation" class="a-input">
<option value="add">Добавить</option>
<option value="remove">Снять</option>
<option value="set">Установить</option>
<option value="add"> Добавить</option>
<option value="remove"> Снять</option>
<option value="set">🎯 Установить</option>
</select>
</div>
<div class="a-modal-actions">
@@ -115,9 +115,10 @@
</div>
</div>
<!-- Выдать предмет -->
<div class="a-overlay" id="giveItemModal">
<div class="a-modal" style="max-width:560px">
<div class="a-modal-title">Выдать предмет</div>
<div class="a-modal-title">🎁 Выдать предмет</div>
<div class="a-mb">
<label class="a-label">Поиск</label>
<input type="text" id="itemSearchInput" class="a-input" placeholder="Введите название..." oninput="searchItems()">
@@ -129,13 +130,14 @@
</div>
</div>
<!-- RPU -->
<div class="a-overlay" id="rpuModal">
<div class="a-modal" style="max-width:500px">
<div class="a-modal-title">Настройка RPU</div>
<div class="a-modal-title">⚙️ Настройка РПУ</div>
<div id="rpuSliders" class="a-mb"></div>
<div class="a-mb">
<label class="a-label" style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
<input type="checkbox" id="rpuAutoAdjust"> Автоматическая подкрутка
<input type="checkbox" id="rpuAutoAdjust"> 🔄 Автоматическая подкрутка
</label>
</div>
<div class="a-modal-actions">
@@ -148,7 +150,7 @@
{% block extra_styles %}
<style>
.slider-group { margin-bottom:0.75rem; }
.slider-group { margin-bottom: 0.75rem; }
.slider-group label { display:flex; justify-content:space-between; font-size:0.78rem; color:rgba(255,255,255,0.6); margin-bottom:0.2rem; }
.slider-group input[type=range] { width:100%; accent-color:#f59e0b; }
#itemSearchResults > div {
@@ -170,6 +172,7 @@ let currentRPU = null;
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
function openModal(id) { document.getElementById(id).classList.add('open'); }
// ====== BALANCE ======
function openBalanceModal() { openModal('balanceModal'); }
async function updateBalance() {
const fd = new FormData();
@@ -181,6 +184,7 @@ async function updateBalance() {
else alert(d.error);
}
// ====== GIVE ITEM ======
function openGiveItemModal() { openModal('giveItemModal'); searchItems(); }
async function searchItems() {
const q = document.getElementById('itemSearchInput').value;
@@ -191,7 +195,7 @@ async function searchItems() {
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="ir-info">
<div class="ir-name">${item.name}</div>
<div class="ir-meta">${item.rarity} &bull; ${item.price_rub || 0} ₽</div>
<div class="ir-meta">${item.rarity} ${item.price_rub || 0} ₽</div>
</div>
<button class="a-btn a-btn-primary a-btn-sm" onclick="giveItem(${item.id})">Выдать</button>
</div>
@@ -205,6 +209,7 @@ async function giveItem(itemId) {
else alert(d.error);
}
// ====== DELETE ITEM ======
async function deleteItem(invId) {
if (!confirm('Удалить предмет?')) return;
const res = await fetch(`/admin/api/user/${userId}/delete-item/${invId}`, { method:'POST' });
@@ -213,6 +218,7 @@ async function deleteItem(invId) {
else alert(d.error);
}
// ====== TOGGLE ADMIN / BAN ======
async function toggleAdmin() {
if (!confirm('Изменить статус админа?')) return;
const res = await fetch(`/admin/api/user/${userId}/toggle-admin`, { method:'POST' });
@@ -226,6 +232,7 @@ async function toggleBan() {
if (d.success) window.location.reload(); else alert(d.error);
}
// ====== RPU ======
async function loadRPU() {
try {
const res = await fetch(`/admin/api/user/${userId}/rpu`);
@@ -237,7 +244,7 @@ async function loadRPU() {
document.getElementById('rpuLuck').textContent = d.luck_multiplier.toFixed(2) + 'x';
document.getElementById('rpuSpent').textContent = Math.round(d.stats.total_spent).toLocaleString() + ' ₽';
document.getElementById('rpuOpened').textContent = d.stats.total_opened.toLocaleString();
document.getElementById('rpuAutoStatus').textContent = d.auto_adjust ? 'DA' : 'NET';
document.getElementById('rpuAutoStatus').textContent = d.auto_adjust ? '' : '';
} catch(_) {}
}
@@ -256,7 +263,7 @@ function openRPUModal() {
oninput="document.getElementById('rp_${k}_v').textContent=this.value+'x'"></div>`;
});
const lv = currentRPU?.luck_multiplier || 1.0;
html += `<div class="slider-group"><label><span>Общая удача</span><span id="rp_luck_v">${lv.toFixed(2)}x</span></label>
html += `<div class="slider-group"><label><span>🍀 Общая удача</span><span id="rp_luck_v">${lv.toFixed(2)}x</span></label>
<input type="range" id="rp_luck" min="0.1" max="3.0" step="0.05" value="${lv}"
oninput="document.getElementById('rp_luck_v').textContent=this.value+'x'"></div>`;
sliders.innerHTML = html;
@@ -274,7 +281,7 @@ async function saveRPU() {
else alert(d.error);
}
async function setRPUPreset(preset) {
const names = { lucky:'Везучий', normal:'Обычный', unlucky:'Невезучий', very_unlucky:'Проклятый' };
const names = { lucky:'🍀 Везучий', normal:'📊 Обычный', unlucky:'🌧️ Невезучий', very_unlucky:'💀 Проклятый' };
if (!confirm('Применить "'+names[preset]+'"?')) return;
const fd = new FormData(); fd.append('preset', preset);
const res = await fetch(`/admin/api/user/${userId}/rpu/preset`, { method:'POST', body:fd });
+31 -6
View File
@@ -13,12 +13,27 @@
{% if users %}
<div class="a-table-wrap">
<table class="a-table">
<thead><tr><th>ID</th><th>Имя</th><th>Баланс</th><th>Статус</th><th>Кейсов</th><th>Контрактов</th><th>Достижений</th><th></th></tr></thead>
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
<th>Баланс</th>
<th>Статус</th>
<th>Кейсов</th>
<th>Контрактов</th>
<th>Достижений</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td><a href="/admin/user/{{ u.id }}" style="color:#f59e0b;text-decoration:none;font-weight:500">{{ u.username }}</a></td>
<td>
<a href="/admin/user/{{ u.id }}" style="color:#f59e0b;text-decoration:none;font-weight:500">
{{ u.username }}
</a>
</td>
<td>{{ "%.0f"|format(u.balance) }} ₽</td>
<td>
{% if u.is_admin %}<span class="a-badge a-badge-admin">Админ</span>{% endif %}
@@ -28,20 +43,30 @@
<td>{{ u.case_openings|default(0) }}</td>
<td>{{ u.contracts|default(0) }}</td>
<td>{{ u.achievements|default(0) }}</td>
<td class="td-actions"><a href="/admin/user/{{ u.id }}" class="a-btn a-btn-sm">✏️</a></td>
<td class="td-actions">
<a href="/admin/user/{{ u.id }}" class="a-btn a-btn-sm">✏️</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="a-empty"><div class="a-empty-icon">📭</div><h3>Пользователи не найдены</h3></div>
<div class="a-empty">
<div class="a-empty-icon">📭</div>
<h3>Пользователи не найдены</h3>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function searchUsers() { const q = document.getElementById('userSearch').value.trim(); window.location.href = '/admin/users' + (q ? '?search='+encodeURIComponent(q) : ''); }
document.getElementById('userSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchUsers(); });
function searchUsers() {
const q = document.getElementById('userSearch').value.trim();
window.location.href = '/admin/users' + (q ? '?search=' + encodeURIComponent(q) : '');
}
document.getElementById('userSearch').addEventListener('keydown', e => {
if (e.key === 'Enter') searchUsers();
});
</script>
{% endblock %}
+579 -539
View File
File diff suppressed because it is too large Load Diff
+212 -56
View File
@@ -3,33 +3,181 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Кейсы CS2 Simulator</title>
<title>Кейсы - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.cases-sections {
display: flex;
flex-direction: column;
gap: 2rem;
}
.cases-container h1 {
font-size: 1.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
.section-tab {
font-size: 1.1rem;
margin-bottom: 0.75rem;
padding-bottom: 0.4rem;
border-bottom: 2px solid var(--primary-color);
display: inline-block;
font-family: 'Russo One', sans-serif;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.section-cases {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1.5rem;
}
.case-card-new {
background: var(--bg-card);
border-radius: 12px;
overflow: hidden;
transition: all 0.3s;
text-decoration: none;
color: inherit;
display: block;
border: 1px solid var(--border-color);
}
.case-card-new:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
border-color: var(--primary-color);
}
.case-image {
width: 100%;
height: 150px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.case-image img {
max-width: 80%;
max-height: 120px;
object-fit: contain;
}
.case-price-badge {
position: absolute;
top: 10px;
right: 10px;
background: var(--bg-dark);
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: bold;
color: var(--success-color);
border: 1px solid var(--success-color);
}
.case-info {
padding: 1rem;
}
.case-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.case-description {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.case-stats {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.case-rarities-mini {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.rarity-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.case-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
}
.case-price {
font-weight: bold;
color: var(--success-color);
}
.btn-open {
padding: 0.4rem 1rem;
background: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.btn-open:hover {
background: var(--primary-hover);
}
</style>
</head>
<body>
<nav class="navbar">
<div class="container">
<a href="/" class="logo">CS2 <span>Simulator</span></a>
<button class="mobile-toggle" id="mobileToggle"></button>
<div class="nav-links" id="navLinks">
<a href="/" class="logo">🎮 CS2 Simulator</a>
<div class="nav-links">
{% if user %}
<a href="/cases" class="nav-link active">Кейсы</a>
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</a>
<a href="/crash" class="nav-link">Crash</a>
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
<a href="/crash" class="nav-link">💥 Crash</a>
<a href="/inventory" class="nav-link">Инвентарь</a>
<a href="/activity" class="nav-link">Лента</a>
<a href="/achievements" class="nav-link">Достижения</a>
<a href="/activity" class="nav-link">📰 Лента</a>
<a href="/achievements" class="nav-link">🏆 Достижения</a>
<a href="/profile" class="nav-link">Профиль</a>
<span class="user-balance">{{ "%.2f"|format(user.balance) }} ₽</span>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-ghost">Выйти</button>
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-outline">Выйти</button>
{% else %}
<a href="/cases" class="nav-link active">Кейсы</a>
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</a>
<a href="/activity" class="nav-link">Лента</a>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
<a href="/activity" class="nav-link">📰 Лента</a>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/login" class="btn btn-outline">Войти</a>
<a href="/register" class="btn btn-primary">Регистрация</a>
{% endif %}
@@ -37,54 +185,62 @@
</div>
</nav>
<main class="section">
<main class="cases-container">
<div class="container">
<div class="section-header">
<h1 class="section-title">📦 Кейсы</h1>
<p class="section-subtitle">Выберите кейс для открытия</p>
</div>
{% for section in sections %}
<div style="margin-bottom:2rem;">
<div class="display-font" style="font-size:0.9rem;margin-bottom:0.75rem;color:var(--primary);letter-spacing:0.04em;">{{ section.tab }}</div>
<div class="grid grid-auto">
{% for case in section.cases %}
<a href="/case/{{ case.name|urlencode }}" class="case-card">
<div class="case-card-image">
{% if case.image_url %}
<img src="{{ case.image_url }}" alt="{{ case.display_name }}" onerror="this.src='/static/placeholder.png'">
{% else %}
<span style="font-size:2.5rem;opacity:0.6;">📦</span>
{% endif %}
</div>
<div class="case-card-body">
<div class="case-card-name">{{ case.display_name }}</div>
{% if case.description %}
<div class="case-card-desc">{{ case.description }}</div>
{% endif %}
</div>
<div class="case-card-footer">
<div class="case-card-price">
{{ "%.0f"|format(case.price) }} ₽
<span>шт.</span>
</div>
<div class="case-card-rarity">
{% for rarity in case.rarities[:4] %}
<span class="case-card-rarity-dot" style="background:var(--rarity-{{ rarity|lower|replace(' ', '-')|replace(' grade', '') }});"></span>
{% endfor %}
{% if case.rarities|length > 4 %}
<span class="text-xs text-dim">+{{ case.rarities|length - 4 }}</span>
<h1>📦 Кейсы</h1>
<p class="page-subtitle">Выберите кейс для открытия</p>
<div class="cases-sections">
{% for section in sections %}
<div class="section-block">
<h2 class="section-tab">{{ section.tab }}</h2>
<div class="section-cases">
{% for case in section.cases %}
<a href="/case/{{ case.name|urlencode }}" class="case-card-new">
<div class="case-image">
{% if case.image_url %}
<img src="{{ case.image_url }}" alt="{{ case.display_name }}" onerror="this.src='/static/placeholder.png'">
{% else %}
<span style="font-size: 3rem;">📦</span>
{% endif %}
<span class="case-price-badge">{{ "%.0f"|format(case.price) }} ₽</span>
</div>
</div>
</a>
{% endfor %}
<div class="case-info">
<div class="case-title">{{ case.display_name }}</div>
{% if case.description %}
<div class="case-description">{{ case.description }}</div>
{% endif %}
<div class="case-stats">
<span>{{ case.items_count }} предметов</span>
<div class="case-rarities-mini">
{% for rarity in case.rarities[:4] %}
<span class="rarity-dot" style="background: var(--rarity-{{ rarity|lower|replace(' ', '-')|replace(' grade', '') }});" title="{{ rarity }}"></span>
{% endfor %}
{% if case.rarities|length > 4 %}
<span style="font-size: 0.7rem;">+{{ case.rarities|length - 4 }}</span>
{% endif %}
</div>
</div>
<div class="case-footer">
<span class="case-price">{{ "%.0f"|format(case.price) }} ₽</span>
<span class="btn-open">Открыть</span>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</main>
<script>
async function logout() {
await fetch('/web/api/auth/logout', { method: 'POST' });
window.location.href = '/';
}
</script>
<script src="/static/js/sounds.js"></script>
<script src="/static/js/websocket.js"></script>
<script src="/static/js/safemode.js"></script>
@@ -100,4 +256,4 @@
</script>
{% if user %}{% include '_activity_sidebar.html' %}{% endif %}
</body>
</html>
</html>
+564 -189
View File
@@ -9,25 +9,25 @@
<body>
<nav class="navbar">
<div class="container">
<a href="/" class="logo">CS2 <span>Simulator</span></a>
<button class="mobile-toggle" id="mobileToggle"></button>
<div class="nav-links" id="navLinks">
<a href="/" class="logo">🎮 CS2 Simulator</a>
<div class="nav-links">
{% if user %}
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link active">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</a>
<a href="/crash" class="nav-link">Crash</a>
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
<a href="/crash" class="nav-link">💥 Crash</a>
<a href="/inventory" class="nav-link">Инвентарь</a>
<a href="/achievements" class="nav-link">Достижения</a>
<a href="/achievements" class="nav-link">🏆 Достижения</a>
<a href="/profile" class="nav-link">Профиль</a>
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-ghost">Выйти</button>
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle">🔊</button>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle">🎙️</button>
<button onclick="logout()" class="btn btn-outline">Выйти</button>
{% else %}
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link active">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</a>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle">🎙️</button>
<a href="/login" class="btn btn-outline">Войти</a>
<a href="/register" class="btn btn-primary">Регистрация</a>
{% endif %}
@@ -35,150 +35,445 @@
</div>
</nav>
<div class="section">
<div class="container" style="max-width:1100px;">
<div class="section-header" style="margin-bottom:1rem;">
<div>
<h1 class="section-title">🔄 Trade Up Контракт</h1>
<p class="section-subtitle">Выберите 10 предметов одинаковой редкости и типа</p>
</div>
</div>
<style>
.ct-page { max-width: 1100px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
.ct-header { text-align: center; margin-bottom: 1.5rem; }
.ct-header h1 { font-size: 1.6rem; font-family: 'Russo One', sans-serif; letter-spacing: 0.05em; }
.ct-header h1 span { color: var(--primary-color); }
.ct-header p { color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.2rem; }
{% if inventory_items and inventory_items|length >= 10 %}
<div style="display:grid;grid-template-columns:1fr 260px;gap:0.75rem;align-items:start;">
<!-- Inventory -->
<div class="card" style="padding:0;overflow:hidden;">
<div style="display:flex;flex-wrap:wrap;gap:0.35rem;align-items:center;padding:0.6rem 0.75rem;border-bottom:1px solid var(--border);background:rgba(0,0,0,0.1);">
<select id="ctRarityFilter" class="form-select" style="width:auto;min-width:120px;font-size:0.78rem;padding:0.3rem 0.5rem;" onchange="ctRenderInv()">
<option value="">Все редкости</option>
{% set rarities = inventory_items|map(attribute='rarity')|unique|list %}
{% for r in rarities %}
<option value="{{ r }}">{{ r }}</option>
{% endfor %}
</select>
<select id="ctTypeFilter" class="form-select" style="width:auto;min-width:100px;font-size:0.78rem;padding:0.3rem 0.5rem;" onchange="ctRenderInv()">
<option value="">Все типы</option>
{% set types = inventory_items|map(attribute='type')|unique|list %}
{% for t in types %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
<input type="text" id="ctSearch" class="form-input" placeholder="🔍 Поиск..." style="flex:1;min-width:100px;font-size:0.78rem;padding:0.3rem 0.5rem;" oninput="ctRenderInv()">
<select id="ctSort" class="form-select" style="width:auto;font-size:0.78rem;padding:0.3rem 0.5rem;" onchange="ctRenderInv()">
<option value="default">По умолч.</option>
<option value="price_asc">Цена ↑</option>
<option value="price_desc">Цена ↓</option>
<option value="name">Имя</option>
<option value="float">Float</option>
</select>
<span class="text-xs text-dim" id="ctInvCount"></span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:0.4rem;padding:0.6rem;max-height:520px;overflow-y:auto;" id="ctInvGrid"></div>
</div>
/* ─── Two-panel layout ─── */
.ct-layout {
display: grid;
grid-template-columns: 1fr 280px;
gap: 1rem;
align-items: start;
}
@media(max-width:800px){
.ct-layout{grid-template-columns:1fr}
.ct-sel-panel{order:-1}
}
<!-- Selection Panel -->
<div class="card" style="position:sticky;top:80px;min-height:300px;" id="ctSelPanel">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<span class="display-font" style="font-size:0.85rem;">📋 Контракт</span>
<span style="font-size:1.2rem;font-weight:700;font-family:var(--font-display);color:var(--primary);" id="ctSelCount" class="">0/10</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.35rem;margin-bottom:0.75rem;" id="ctSelGrid">
{% for i in range(10) %}
<div style="background:rgba(0,0,0,0.12);border:1px dashed rgba(255,255,255,0.06);border-radius:8px;padding:0.3rem;min-height:48px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0.1rem;transition:all 0.3s;" class="ct-sel-slot" id="ctSlot-{{ i }}" data-idx="{{ i }}">
<span style="font-size:0.5rem;color:var(--text-dim);">{{ i+1 }}</span>
</div>
{% endfor %}
</div>
<div class="text-xs text-dim text-center" style="padding:1rem 0;display:block;" id="ctSelHint">Выберите предмет из инвентаря</div>
<div style="display:flex;flex-direction:column;gap:0.35rem;">
<button class="btn btn-ghost btn-sm" onclick="ctClearAll()" style="color:var(--text-dim);border:1px solid var(--border);">✕ Сбросить</button>
<button class="btn btn-success btn-sm" id="ctSubmitBtn" disabled onclick="ctOpenPaper()">🔄 Заключить контракт</button>
{% if knife_count >= 10 %}
<button class="btn btn-sm" id="ctKnifeBtn" onclick="ctOpenKnife()" style="background:linear-gradient(135deg,#ffd700,#f97316);color:#000;">🔞 Крафт дилдок ×{{ knife_count }}</button>
{% endif %}
</div>
</div>
</div>
/* ─── Left: Inventory panel ─── */
.ct-inv-panel {
background: rgba(20,21,26,0.5);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.ct-inv-toolbar {
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
background: rgba(0,0,0,0.15);
}
.ct-inv-toolbar select, .ct-inv-toolbar input {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
border-radius: 6px;
color: white;
padding: 0.35rem 0.6rem;
font-size: 0.78rem;
outline: none;
transition: border-color 0.2s;
}
.ct-inv-toolbar select:focus, .ct-inv-toolbar input:focus {
border-color: var(--primary-color);
}
.ct-inv-toolbar input {
flex: 1; min-width: 100px;
}
{% if not user %}
<div class="empty-state">
<div class="empty-state-icon">🔒</div>
<div class="empty-state-title">Контракты доступны только авторизованным</div>
<div class="empty-state-desc">Войдите в аккаунт, чтобы просматривать инвентарь и заключать контракты</div>
<div class="flex gap-2 justify-center">
<a href="/login" class="btn btn-primary">Войти</a>
<a href="/register" class="btn btn-outline">Регистрация</a>
</div>
</div>
{% endif %}
{% else %}
<div class="empty-state">
{% if user %}
<div style="font-size:2rem;margin-bottom:0.5rem;">📭</div>
<div class="empty-state-title">Недостаточно предметов</div>
<div class="empty-state-desc">Нужно минимум 10 предметов одинаковой редкости и типа</div>
<a href="/cases" class="btn btn-primary">Открыть кейсы</a>
{% else %}
<div style="font-size:2rem;margin-bottom:0.5rem;">🔒</div>
<div class="empty-state-title">Контракты доступны только авторизованным</div>
<div class="empty-state-desc">Войдите в аккаунт, чтобы просматривать инвентарь и заключать контракты</div>
<a href="/login" class="btn btn-primary">Войти</a>
<a href="/register" class="btn btn-outline">Регистрация</a>
{% endif %}
</div>
{% endif %}
.ct-inv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.5rem;
padding: 0.75rem;
max-height: 560px;
overflow-y: auto;
}
.ct-inv-grid::-webkit-scrollbar { width: 4px; }
.ct-inv-grid::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 2px; }
.ct-card {
position: relative;
background: rgba(0,0,0,0.2);
border: 2px solid transparent;
border-radius: 8px;
padding: 0.5rem 0.4rem;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.34,1.56,0.64,1);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
user-select: none;
animation: ctCardIn 0.25s ease both;
}
@keyframes ctCardIn { from{opacity:0;transform:translateY(8px) scale(0.95)} to{opacity:1;transform:translateY(0) scale(1)} }
.ct-card:hover { border-color: rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); }
.ct-card:active { transform: scale(0.96); }
.ct-card img {
width: 100%; height: 56px;
object-fit: contain; pointer-events: none;
transition: transform 0.3s;
}
.ct-card:hover img { transform: scale(1.06); }
.ct-card .ct-name {
font-size: 0.6rem; text-align: center; line-height: 1.1;
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.ct-card .ct-float { font-size: 0.55rem; color: var(--text-secondary); }
.ct-card.selected {
border-color: var(--success-color);
background: rgba(16,185,129,0.08);
}
.ct-card.spent {
opacity: 0.2;
cursor: not-allowed;
filter: grayscale(0.8);
pointer-events: none;
}
.ct-card.spent::after {
content: '✔';
position: absolute; top: 4px; right: 4px;
font-size: 0.65rem; color: var(--success-color);
font-weight: 800;
}
.ct-card.blocked {
opacity: 0.25;
cursor: not-allowed;
filter: grayscale(0.8);
}
.ct-card.knife-item { border-color: rgba(255,215,0,0.12); }
.ct-card .ct-knife-badge {
position: absolute; top: 3px; left: 3px;
font-size: 0.55rem; padding: 0.05rem 0.25rem;
background: rgba(255,215,0,0.15); color: #ffd700;
border-radius: 3px; font-weight: 700;
}
/* ─── Right: Selection panel ─── */
.ct-sel-panel {
background: rgba(20,21,26,0.5);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 1rem;
position: sticky; top: 80px;
min-height: 300px;
}
.ct-sel-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1rem;
}
.ct-sel-header h3 {
font-family: 'Russo One', sans-serif;
font-size: 0.9rem;
letter-spacing: 0.04em;
}
.ct-sel-header .ct-sel-count {
font-size: 1.3rem;
font-weight: 800;
font-family: 'Russo One', sans-serif;
color: var(--primary-color);
}
.ct-sel-count.full { color: var(--success-color); }
.ct-sel-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.4rem;
margin-bottom: 1rem;
}
.ct-sel-slot {
background: rgba(0,0,0,0.15);
border: 1px dashed rgba(255,255,255,0.06);
border-radius: 8px;
padding: 0.35rem;
min-height: 52px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
transition: all 0.3s;
position: relative;
}
.ct-sel-slot.fill {
border-color: rgba(255,255,255,0.1);
border-style: solid;
background: rgba(255,255,255,0.02);
}
.ct-sel-slot img {
width: 100%; height: 28px;
object-fit: contain;
}
.ct-sel-slot .s-name {
font-size: 0.48rem; text-align: center; line-height: 1.05;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%;
}
.ct-sel-slot .s-remove {
position: absolute; top: 1px; right: 1px;
width: 16px; height: 16px; border-radius: 50%;
background: rgba(239,68,68,0.8); color: white;
border: none; font-size: 0.55rem; cursor: pointer;
display: none; align-items: center; justify-content: center;
line-height: 1;
}
.ct-sel-slot.fill .s-remove { display: flex; }
.ct-sel-slot .s-remove:hover { background: rgba(239,68,68,1); transform: scale(1.15); }
.ct-sel-slot .s-num {
font-size: 0.5rem; color: var(--text-secondary);
}
.ct-sel-actions {
display: flex; flex-direction: column; gap: 0.4rem;
}
.ct-sel-actions button {
width: 100%; padding: 0.5rem;
border: none; border-radius: 8px;
font-family: 'Russo One', sans-serif;
font-size: 0.75rem;
letter-spacing: 0.04em;
cursor: pointer;
transition: all 0.25s;
}
.ct-sel-btn {
background: linear-gradient(135deg, var(--success-color), #059669);
color: white;
}
.ct-sel-btn:hover:not(:disabled) {
filter: brightness(1.1);
box-shadow: 0 4px 20px rgba(16,185,129,0.3);
}
.ct-sel-btn:disabled {
opacity: 0.3; cursor: not-allowed;
}
.ct-sel-btn.knife {
background: linear-gradient(135deg, #ffd700, #f97316);
}
.ct-sel-btn.knife:hover:not(:disabled) {
box-shadow: 0 4px 20px rgba(255,215,0,0.3);
}
.ct-sel-clear {
background: rgba(255,255,255,0.04);
border: 1px solid var(--border-color) !important;
color: var(--text-secondary);
}
.ct-sel-clear:hover { border-color: var(--danger-color) !important; color: var(--danger-color); }
.ct-sel-hint {
text-align: center;
font-size: 0.7rem;
color: var(--text-secondary);
padding: 1.5rem 0;
}
/* ─── Modals (unchanged) ─── */
.ct-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.7); backdrop-filter: blur(8px);
z-index: 200; display: none; align-items: center; justify-content: center;
padding: 1rem; animation: ctOvlIn 0.25s ease;
}
.ct-overlay.open { display: flex; }
@keyframes ctOvlIn { from{opacity:0} to{opacity:1} }
.ct-paper {
background: linear-gradient(145deg, #1c1d24, #14151a);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 20px; max-width: 640px; width: 100%; padding: 2rem;
position: relative; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
animation: ctPaperIn 0.4s cubic-bezier(0.34,1.56,0.64,1);
}
@keyframes ctPaperIn { from{opacity:0;transform:scale(0.92) translateY(20px)} to{opacity:1;transform:scale(1) translateY(0)} }
.ct-paper-title { text-align: center; font-family:'Russo One',sans-serif; font-size:1.3rem; letter-spacing:0.08em; }
.ct-paper-sub { text-align: center; font-size:0.75rem; color:var(--text-secondary); letter-spacing:0.05em; margin-bottom:1.5rem; }
.ct-paper-line { height:1px; background:linear-gradient(90deg,transparent,rgba(255,255,255,0.08),transparent); margin-bottom:1.25rem; }
.ct-paper-grid { display:grid; grid-template-columns:repeat(5,1fr); gap:0.5rem; margin-bottom:1.25rem; }
.ct-paper-slot {
background:rgba(0,0,0,0.15); border:1px dashed rgba(255,255,255,0.06);
border-radius:8px; padding:0.35rem; min-height:60px;
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:0.15rem; transition:all 0.3s;
}
.ct-paper-slot.fill { border-color:rgba(255,255,255,0.12); border-style:solid; background:rgba(255,255,255,0.02); }
.ct-paper-slot img { width:100%; height:30px; object-fit:contain; }
.ct-paper-slot .s-name { font-size:0.5rem; text-align:center; line-height:1.1; }
.ct-sign-area { display:flex; align-items:center; gap:0.75rem; margin-bottom:0.75rem; }
.ct-sign-canvas { flex:1; border:1px dashed rgba(255,255,255,0.1); border-radius:6px; cursor:crosshair; background:#0d0e12; height:50px; }
.ct-stamp { text-align:center; font-family:'Russo One',sans-serif; font-size:1.4rem; letter-spacing:0.15em; color:var(--success-color); opacity:0; transition:opacity 0.5s; }
.ct-stamp.active { opacity:1; }
.ct-sign-btn {
width:100%; padding:0.7rem; border:none; border-radius:10px;
background:linear-gradient(135deg,var(--primary-color),#f97316); color:white;
font-family:'Russo One',sans-serif; font-size:0.85rem; letter-spacing:0.06em;
cursor:pointer; transition:all 0.25s; margin-top:0.75rem;
}
.ct-sign-btn:hover{filter:brightness(1.1);box-shadow:0 4px 24px rgba(245,158,11,0.3)}
.ct-sign-btn:disabled{opacity:0.5;cursor:not-allowed;filter:none;box-shadow:none}
.ct-result { text-align:center; }
.ct-result-img { width:160px; height:120px; object-fit:contain; margin-bottom:0.75rem; }
.ct-result-name { font-size:1.15rem; font-weight:700; margin-bottom:0.3rem; }
.ct-result-rarity { font-size:0.85rem; font-weight:600; margin-bottom:0.4rem; }
.ct-result-detail { font-size:0.75rem; color:var(--text-secondary); margin-bottom:0.15rem; }
.ct-result-btn {
margin-top:1.25rem; padding:0.6rem 1.8rem; border:none; border-radius:8px;
background:linear-gradient(135deg,var(--primary-color),#f97316); color:white;
font-family:'Russo One',sans-serif; font-size:0.85rem; cursor:pointer; transition:all 0.25s;
}
.ct-result-btn:hover{filter:brightness(1.1);box-shadow:0 4px 24px rgba(245,158,11,0.3)}
.ct-empty {
text-align: center; padding: 3rem 1rem;
color: var(--text-secondary);
}
.ct-empty h3 { margin-bottom: 0.5rem; }
.ct-empty p { font-size: 0.85rem; margin-bottom: 1rem; }
</style>
<div class="ct-page">
<div class="ct-header">
<h1>🔄 <span>Trade Up</span> Контракт</h1>
<p>Выберите 10 предметов одинаковой редкости и типа</p>
</div>
</div>
<!-- Paper Modal -->
<div class="modal-overlay" id="ctPaperModal" style="display:none;">
<div class="modal" style="max-width:500px;">
<div class="modal-header">
<div class="modal-title">📜 TRADE UP CONTRACT</div>
<button class="modal-close" onclick="ctClosePaper()"></button>
{% if inventory_items and inventory_items|length >= 10 %}
<div class="ct-layout">
<!-- Left: Inventory -->
<div class="ct-inv-panel">
<div class="ct-inv-toolbar">
<select id="ctRarityFilter" onchange="ctRenderInv()">
<option value="">Все редкости</option>
{% set rarities = inventory_items|map(attribute='rarity')|unique|list %}
{% for r in rarities %}
<option value="{{ r }}">{{ r }}</option>
{% endfor %}
</select>
<select id="ctTypeFilter" onchange="ctRenderInv()">
<option value="">Все типы</option>
{% set types = inventory_items|map(attribute='type')|unique|list %}
{% for t in types %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
<input type="text" id="ctSearch" placeholder="🔍 Поиск..." oninput="ctRenderInv()">
<select id="ctSort" onchange="ctRenderInv()">
<option value="default">По умолчанию</option>
<option value="price_asc">Цена ↑</option>
<option value="price_desc">Цена ↓</option>
<option value="name">По названию</option>
<option value="float">Float</option>
</select>
<span style="font-size:0.7rem;color:var(--text-secondary)" id="ctInvCount"></span>
</div>
<div class="ct-inv-grid" id="ctInvGrid"></div>
</div>
<div class="modal-body">
<p class="text-xs text-dim text-center" style="margin-bottom:1rem;">CS2 Weapon Case — проверка подлинности</p>
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.35rem;margin-bottom:1rem;" id="ctPaperSlots">
<!-- Right: Selection -->
<div class="ct-sel-panel" id="ctSelPanel">
<div class="ct-sel-header">
<h3>📋 Контракт</h3>
<span class="ct-sel-count" id="ctSelCount">0/10</span>
</div>
<div class="ct-sel-grid" id="ctSelGrid">
{% for i in range(10) %}
<div style="background:rgba(0,0,0,0.12);border:1px dashed rgba(255,255,255,0.06);border-radius:8px;padding:0.35rem;min-height:56px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0.1rem;" class="ct-paper-slot" id="ctSlotPaper{{ i }}"><span style="font-size:0.5rem;color:var(--text-dim)">{{ i+1 }}</span></div>
<div class="ct-sel-slot" id="ctSlot-{{ i }}" data-idx="{{ i }}">
<span class="s-num">{{ i+1 }}</span>
</div>
{% endfor %}
</div>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
<canvas id="ctSignCanvas" width="400" height="50" style="flex:1;border:1px dashed rgba(255,255,255,0.1);border-radius:6px;cursor:crosshair;background:#0d0e12;height:50px;"></canvas>
<button class="btn btn-ghost btn-sm" onclick="ctClearSign()" style="flex-shrink:0;"></button>
<div class="ct-sel-hint" id="ctSelHint">Выберите предмет из инвентаря</div>
<div class="ct-sel-actions">
<button class="ct-sel-clear" onclick="ctClearAll()">✕ Сбросить</button>
<button class="ct-sel-btn" id="ctSubmitBtn" disabled onclick="ctOpenPaper()">🔄 Заключить контракт</button>
{% if knife_count >= 10 %}
<button class="ct-sel-btn knife" id="ctKnifeBtn" onclick="ctOpenKnife()">🔞 Крафт дилдок ×{{ knife_count }}</button>
{% endif %}
</div>
<div class="display-font text-center" style="font-size:1.2rem;color:var(--success);opacity:0;transition:opacity 0.5s;" id="ctStamp">✔ УТВЕРЖДЕНО</div>
<button class="btn btn-primary btn-block" id="ctSignBtn" onclick="ctSign()" style="margin-top:0.75rem;">✍ ПОДПИСАТЬ</button>
</div>
</div>
{% else %}
<div class="ct-empty">
{% if user %}
<div style="font-size:2rem;margin-bottom:0.5rem">📭</div>
<h3>Недостаточно предметов</h3>
<p>Нужно минимум 10 предметов одинаковой редкости и типа</p>
<a href="/cases" class="btn btn-primary">Открыть кейсы</a>
{% else %}
<div style="font-size:2rem;margin-bottom:0.5rem">🔒</div>
<h3>Контракты доступны только авторизованным</h3>
<p>Войдите в аккаунт, чтобы просматривать инвентарь и заключать контракты</p>
<a href="/login" class="btn btn-primary">Войти</a>
<a href="/register" class="btn btn-outline">Регистрация</a>
{% endif %}
</div>
{% endif %}
</div>
<!-- Result Modal -->
<div class="modal-overlay" id="ctResultModal" style="display:none;">
<div class="modal" style="max-width:380px;">
<div class="modal-body text-center" id="ctResultContent"></div>
<!-- Модалка бумаги -->
<div class="ct-overlay" id="ctPaperModal">
<div class="ct-paper">
<div class="ct-paper-title">📜 TRADE UP CONTRACT</div>
<div class="ct-paper-sub">CS2 Weapon Case — проверка подлинности</div>
<div class="ct-paper-line"></div>
<div class="ct-paper-grid" id="ctPaperSlots"></div>
<div class="ct-paper-line"></div>
<div class="ct-sign-area">
<canvas class="ct-sign-canvas" id="ctSignCanvas" width="400" height="50"></canvas>
<button class="ct-sel-clear" onclick="ctClearSign()" style="padding:0.3rem 0.6rem"></button>
</div>
<div class="ct-stamp" id="ctStamp">✔ УТВЕРЖДЕНО</div>
<button class="ct-sign-btn" id="ctSignBtn" onclick="ctSign()">✍ ПОДПИСАТЬ</button>
</div>
</div>
<script src="/static/js/sounds.js"></script>
<script src="/static/js/websocket.js"></script>
<script src="/static/js/safemode.js"></script>
<script>
const allItems = [{% for item in inventory_items %}{invId:{{ item.inventory_id }},name:{{ item.name|tojson }},rarity:{{ item.rarity|tojson }},type:{{ item.type|tojson }},float:{{ item.float }},image:{{ item.image_url|tojson }},isKnife:{{ 1 if item.is_knife else 0 }}}{% if not loop.last %},{% endif %}{% endfor %}];
const selected = new Map();
let ctMode = '';
<!-- Модалка результата -->
<div class="ct-overlay" id="ctResultModal">
<div class="ct-paper" style="max-width:420px">
<div class="ct-result" id="ctResultContent"></div>
</div>
</div>
<script>
// ─── Данные ────────────────────────────────────────────────────────────
const allItems = [
{% for item in inventory_items %}
{
invId: {{ item.inventory_id }},
name: {{ item.name|tojson }},
rarity: {{ item.rarity|tojson }},
type: {{ item.type|tojson }},
float: {{ item.float }},
image: {{ item.image_url|tojson }},
isKnife: {{ 1 if item.is_knife else 0 }}
}{% if not loop.last %},{% endif %}
{% endfor %}
];
const selected = new Map(); // invId -> {data, idx}
let ctMode = ''; // '' | 'knife' | 'normal'
// ─── Render inventory ──────────────────────────────────────────────────
function ctRenderInv() {
const rarity = document.getElementById('ctRarityFilter').value;
const type = document.getElementById('ctTypeFilter').value;
const q = document.getElementById('ctSearch').value.toLowerCase();
const sort = document.getElementById('ctSort').value;
let items = allItems.filter(it => {
if (selected.has(it.invId)) return false;
if (rarity && it.rarity !== rarity) return false;
if (type && it.type !== type) return false;
if (q && !it.name.toLowerCase().includes(q)) return false;
// Если уже есть выбор — показываем только совместимые
if (ctMode === 'knife' && !it.isKnife) return false;
if (ctMode === 'normal') {
const first = selected.values().next().value;
@@ -186,111 +481,177 @@
}
return true;
});
switch (sort) {
case 'price_asc': items.sort((a,b) => (a.float||0)-(b.float||0)); break;
case 'price_desc': items.sort((a,b) => (b.float||0)-(a.float||0)); break;
case 'price_asc': items.sort((a,b) => (a.float||0) - (b.float||0)); break;
case 'price_desc': items.sort((a,b) => (b.float||0) - (a.float||0)); break;
case 'name': items.sort((a,b) => a.name.localeCompare(b.name)); break;
case 'float': items.sort((a,b) => a.float - b.float); break;
}
document.getElementById('ctInvCount').textContent = items.length;
const grid = document.getElementById('ctInvGrid');
grid.innerHTML = items.map((it,i) => `<div style="background:rgba(0,0,0,0.15);border:2px solid transparent;border-radius:8px;padding:0.4rem 0.35rem;cursor:pointer;transition:all 0.2s;display:flex;flex-direction:column;align-items:center;gap:0.15rem;animation:slideInUp 0.25s ease both;animation-delay:${(i%30)*0.02}s;${it.isKnife?'border-color:rgba(255,215,0,0.12);':''}" onclick="ctPick(${it.invId})" onmouseover="this.style.borderColor='rgba(255,255,255,0.1)'" onmouseout="this.style.borderColor='transparent'">
${it.isKnife ? '<span style="position:absolute;top:2px;left:2px;font-size:0.5rem;padding:0.05rem 0.25rem;background:rgba(255,215,0,0.15);color:#ffd700;border-radius:3px;font-weight:700;">🔪</span>' : ''}
<img src="${it.image||'/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" loading="lazy" style="width:100%;height:48px;object-fit:contain;">
<div style="font-size:0.55rem;text-align:center;line-height:1.1;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;">${it.name}</div>
<div style="font-size:0.5rem;color:var(--text-dim);">★ ${it.float.toFixed(4)}</div>
</div>`).join('');
grid.innerHTML = items.map((it, i) => `
<div class="ct-card ${it.isKnife ? 'knife-item' : ''}"
onclick="ctPick(${it.invId})"
style="animation-delay:${(i%30)*0.025}s"
data-inv="${it.invId}">
${it.isKnife ? '<div class="ct-knife-badge">🔪</div>' : ''}
<img src="${it.image || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" loading="lazy">
<div class="ct-name">${it.name}</div>
<div class="ct-float">★ ${it.float.toFixed(4)}</div>
</div>
`).join('');
}
// ─── Pick / Unpick ────────────────────────────────────────────────────
function ctPick(invId) {
const data = allItems.find(it => it.invId === invId);
if (!data) return;
if (selected.size === 0) ctMode = data.isKnife ? 'knife' : 'normal';
// Первый выбор — устанавливаем режим
if (selected.size === 0) {
ctMode = data.isKnife ? 'knife' : 'normal';
}
// Проверка совместимости
if (ctMode === 'knife' && !data.isKnife) return;
if (ctMode === 'normal') {
const first = selected.values().next().value;
const ref = first ? first.data : data;
if (data.isKnife || data.rarity !== ref.rarity || data.type !== ref.type) return;
}
if (selected.size >= 10) return;
selected.set(invId, { data, idx: selected.size });
ctRenderInv(); ctRenderSel();
ctRenderInv();
ctRenderSel();
}
function ctUnpick(invId) {
if (!selected.has(invId)) return;
selected.delete(invId);
let i = 0; for (const [,v] of selected) v.idx = i++;
// Перенумеровать
let i = 0;
for (const [id, v] of selected) {
v.idx = i++;
}
if (selected.size === 0) ctMode = '';
ctRenderInv(); ctRenderSel();
ctRenderInv();
ctRenderSel();
}
function ctClearAll() { selected.clear(); ctMode = ''; ctRenderInv(); ctRenderSel(); }
function ctClearAll() {
selected.clear();
ctMode = '';
ctRenderInv();
ctRenderSel();
}
// ─── Render selection panel ───────────────────────────────────────────
function ctRenderSel() {
const count = document.getElementById('ctSelCount');
const hint = document.getElementById('ctSelHint');
const submitBtn = document.getElementById('ctSubmitBtn');
const knifeBtn = document.getElementById('ctKnifeBtn');
count.textContent = selected.size+'/10';
count.style.color = selected.size === 10 ? 'var(--success)' : 'var(--primary)';
if (selected.size === 0) { hint.style.display = 'block'; submitBtn.style.display = 'none'; if (knifeBtn) knifeBtn.style.display = 'none'; }
else { hint.style.display = 'none';
if (ctMode === 'knife') { submitBtn.style.display = 'none'; if (knifeBtn) knifeBtn.style.display = selected.size === 10 ? '' : 'none'; }
else { if (knifeBtn) knifeBtn.style.display = 'none'; submitBtn.style.display = selected.size === 10 ? '' : 'none'; }
count.textContent = selected.size + '/10';
count.classList.toggle('full', selected.size === 10);
if (selected.size === 0) {
hint.style.display = 'block';
submitBtn.style.display = 'none';
if (knifeBtn) knifeBtn.style.display = 'none';
} else {
hint.style.display = 'none';
if (ctMode === 'knife') {
submitBtn.style.display = 'none';
if (knifeBtn) knifeBtn.style.display = selected.size === 10 ? '' : 'none';
} else {
if (knifeBtn) knifeBtn.style.display = 'none';
submitBtn.style.display = selected.size === 10 ? '' : 'none';
}
}
submitBtn.disabled = selected.size !== 10;
// Fill slots
const items = [...selected.entries()].sort((a,b) => a[1].idx - b[1].idx);
for (let i = 0; i < 10; i++) {
const slot = document.getElementById('ctSlot-'+i);
const slot = document.getElementById('ctSlot-' + i);
const entry = items[i];
if (entry) {
const [id, v] = entry;
slot.style.borderStyle = 'solid'; slot.style.borderColor = 'rgba(255,255,255,0.1)'; slot.style.background = 'rgba(255,255,255,0.02)';
slot.innerHTML = `<button style="position:absolute;top:1px;right:1px;width:14px;height:14px;border-radius:50%;background:rgba(239,68,68,0.8);color:white;border:none;font-size:0.45rem;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;" onclick="ctUnpick(${id})"></button>
<img src="${v.data.image||'/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" style="width:100%;height:24px;object-fit:contain;">
<div style="font-size:0.45rem;text-align:center;line-height:1.05;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;width:100%;">${v.data.name}</div>`;
slot.className = 'ct-sel-slot fill';
slot.innerHTML = `
<button class="s-remove" onclick="ctUnpick(${id})"></button>
<img src="${v.data.image || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="s-name">${v.data.name}</div>
`;
} else {
slot.style.borderStyle = 'dashed'; slot.style.borderColor = 'rgba(255,255,255,0.06)'; slot.style.background = '';
slot.innerHTML = `<span style="font-size:0.5rem;color:var(--text-dim);">${i+1}</span>`;
slot.className = 'ct-sel-slot';
slot.innerHTML = `<span class="s-num">${i+1}</span>`;
}
}
document.getElementById('ctSubmitBtn').textContent = ctMode === 'knife' ? '🔞 Крафт дилдок' : '🔄 Заключить контракт';
}
// ─── Paper modal ──────────────────────────────────────────────────────
function ctOpenPaper() {
if (selected.size !== 10) return;
const items = [...selected.entries()].sort((a,b) => a[1].idx - b[1].idx).map(([id,v]) => ({invId:id,name:v.data.name,rarity:v.data.rarity,float:v.data.float,image:v.data.image}));
const items = [...selected.entries()].sort((a,b) => a[1].idx - b[1].idx).map(([id, v]) => ({
invId: id,
name: v.data.name,
rarity: v.data.rarity,
float: v.data.float,
image: v.data.image
}));
window.__ctItems = items;
const slots = document.getElementById('ctPaperSlots');
slots.innerHTML = '';
for (let i = 0; i < 10; i++) {
const s = document.createElement('div'); s.style.cssText = 'background:rgba(0,0,0,0.12);border:1px dashed rgba(255,255,255,0.06);border-radius:8px;padding:0.35rem;min-height:56px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0.1rem;'; s.id = 'ctSlotPaper'+i;
s.innerHTML = '<span style="font-size:0.5rem;color:var(--text-dim)">'+(i+1)+'</span>'; slots.appendChild(s);
const s = document.createElement('div');
s.className = 'ct-paper-slot';
s.id = 'ctSlotPaper' + i;
s.innerHTML = '<span style="font-size:0.5rem;color:var(--text-secondary)">' + (i+1) + '</span>';
slots.appendChild(s);
}
document.getElementById('ctPaperModal').style.display = 'flex';
document.getElementById('ctStamp').style.opacity = '0';
document.getElementById('ctPaperModal').classList.add('open');
document.getElementById('ctStamp').classList.remove('active');
setTimeout(ctInitSign, 100);
ctAnimateSlots();
}
function ctOpenKnife() { if (selected.size !== 10) return; ctOpenPaper(); }
function ctOpenKnife() {
if (selected.size !== 10) return;
ctOpenPaper();
}
async function ctAnimateSlots() {
const items = window.__ctItems || [];
for (let i = 0; i < 10; i++) {
const s = document.getElementById('ctSlotPaper'+i);
const item = items[i]; s.style.borderStyle = 'solid'; s.style.borderColor = 'rgba(255,255,255,0.1)'; s.style.background = 'rgba(255,255,255,0.02)';
s.innerHTML = `<img src="${item.image}" onerror="this.src='/static/placeholder.png'" style="width:100%;height:28px;object-fit:contain;"><div style="font-size:0.48rem;text-align:center;line-height:1.1;">${item.name}</div>`;
await new Promise(r => setTimeout(r, 40 + Math.random()*30));
const s = document.getElementById('ctSlotPaper' + i);
const item = items[i];
s.classList.add('fill');
s.innerHTML = `<img src="${item.image}" onerror="this.src='/static/placeholder.png'">
<div class="s-name">${item.name}</div>`;
await new Promise(r => setTimeout(r, 50 + Math.random() * 40));
}
await new Promise(r => setTimeout(r, 250));
document.getElementById('ctStamp').style.opacity = '1';
document.getElementById('ctStamp').classList.add('active');
}
let ctSignCtx = null;
function ctInitSign() {
const c = document.getElementById('ctSignCanvas'); const ctx = c.getContext('2d'); ctSignCtx = ctx;
ctx.fillStyle = '#0d0e12'; ctx.fillRect(0,0,c.width,c.height); ctx.strokeStyle = '#d4c5a9'; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
const c = document.getElementById('ctSignCanvas');
const ctx = c.getContext('2d');
ctSignCtx = ctx;
ctx.fillStyle = '#0d0e12'; ctx.fillRect(0,0,c.width,c.height);
ctx.strokeStyle = '#d4c5a9'; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
let drawing = false;
const s = e => { const r=c.getBoundingClientRect(); drawing=true; ctx.beginPath(); ctx.moveTo(e.clientX-r.left, e.clientY-r.top); };
const m = e => { if(!drawing)return; const r=c.getBoundingClientRect(); ctx.lineTo(e.clientX-r.left, e.clientY-r.top); ctx.stroke(); };
@@ -300,64 +661,78 @@
c.ontouchmove = e=>{e.preventDefault();if(!drawing)return;const t=e.touches[0];const r=c.getBoundingClientRect();ctx.lineTo(t.clientX-r.left,t.clientY-r.top);ctx.stroke();};
c.ontouchend = ()=>drawing=false;
}
function ctClearSign() { const c = document.getElementById('ctSignCanvas'); if(ctSignCtx){ctSignCtx.fillStyle='#0d0e12';ctSignCtx.fillRect(0,0,c.width,c.height);} }
function ctClearSign() {
const c = document.getElementById('ctSignCanvas');
if(ctSignCtx){ctSignCtx.fillStyle='#0d0e12';ctSignCtx.fillRect(0,0,c.width,c.height);}
}
async function ctSign() {
const btn = document.getElementById('ctSignBtn'); btn.disabled = true; btn.textContent = '⏳ ПОДПИСЫВАЕМ...';
const btn = document.getElementById('ctSignBtn');
btn.disabled = true; btn.textContent = '⏳ ПОДПИСЫВАЕМ...';
SoundManager.contractSubmit();
const items = window.__ctItems || [];
const fd = new FormData(); fd.append('inventory_ids', JSON.stringify(items.map(i=>i.invId)));
const fd = new FormData();
fd.append('inventory_ids', JSON.stringify(items.map(i => i.invId)));
try {
const res = await fetch('/web/api/contracts/submit', { method:'POST', body:fd });
const res = await fetch('/web/api/contracts/submit', { method: 'POST', body: fd });
const data = await res.json();
if (res.ok && data.success) {
if (typeof handleAchievements === 'function') handleAchievements(data);
SoundManager.contractResult(); ctClosePaper();
SoundManager.contractResult();
ctClosePaper();
setTimeout(() => ctShowResult(data.received_item, data.is_knife_contract), 200);
} else { SoundManager.error(); alert(data.error); ctClosePaper(); }
} else {
SoundManager.error(); alert(data.error || 'Ошибка'); ctClosePaper();
}
} catch(_) { SoundManager.error(); alert('Ошибка соединения'); ctClosePaper(); }
btn.disabled = false; btn.textContent = '✍ ПОДПИСАТЬ';
}
function ctShowResult(item, isKnife) {
const g = getRarityColor(item.rarity);
const g = getComputedStyle(document.documentElement).getPropertyValue('--rarity-' + (item.rarity||'').toLowerCase().replace(/ /g, '-').split('-')[0]).trim() || '#ffd700';
const title = isKnife ? '🔞 КОНТРАКТ ВЫПОЛНЕН' : '🎉 КОНТРАКТ ВЫПОЛНЕН';
document.getElementById('ctResultContent').innerHTML = `
<div style="font-size:2rem;margin-bottom:0.5rem">${isKnife ? '🔞' : '🎉'}</div>
<div class="display-font" style="font-size:1rem;letter-spacing:0.04em;margin-bottom:1rem">${title}</div>
<img src="${item.image_url||'/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" style="width:120px;height:90px;object-fit:contain;margin-bottom:0.5rem;filter:drop-shadow(0 0 20px ${g})">
<div class="display-font" style="font-size:1rem;">${item.name}</div>
<div style="color:${g};font-weight:600;font-size:0.85rem;margin-bottom:0.3rem;">${item.rarity}</div>
<div class="text-xs text-dim">Float: ${item.float.toFixed(6)}</div>
<div class="text-xs text-dim">Шанс: ${(item.probability||0).toFixed(2)}%</div>
<button class="btn btn-primary" onclick="ctCloseResult()" style="margin-top:1rem;">Продолжить</button>
<div style="--g:${g}">
<div style="font-size:2rem;margin-bottom:0.5rem">${isKnife ? '🔞' : '🎉'}</div>
<div style="font-family:'Russo One',sans-serif;font-size:1.1rem;letter-spacing:0.04em;margin-bottom:1rem">${title}</div>
<img class="ct-result-img" src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" style="filter:drop-shadow(0 0 30px var(--g))">
<div class="ct-result-name">${item.name}</div>
<div class="ct-result-rarity" style="color:${g}">${item.rarity}</div>
<div class="ct-result-detail">Float: ${item.float.toFixed(6)}</div>
<div class="ct-result-detail">Шанс: ${(item.probability || 0).toFixed(2)}%</div>
<button class="ct-result-btn" onclick="ctCloseResult()">Продолжить</button>
</div>
`;
document.getElementById('ctResultModal').style.display = 'flex';
document.getElementById('ctResultModal').classList.add('open');
}
function ctClosePaper() { document.getElementById('ctPaperModal').style.display = 'none'; }
function ctCloseResult() { document.getElementById('ctResultModal').style.display = 'none'; window.location.reload(); }
function ctClosePaper() { document.getElementById('ctPaperModal').classList.remove('open'); }
function ctCloseResult() { document.getElementById('ctResultModal').classList.remove('open'); window.location.reload(); }
function handleAchievements(d) {
if(d && d.achievements_unlocked && Array.isArray(d.achievements_unlocked) && d.achievements_unlocked.length)
d.achievements_unlocked.forEach(a => showAchievementPopup(a.title || a.name || 'Достижение', a.reward || ''));
}
function toggleSound() {
const enabled = SoundManager.toggle();
document.getElementById('soundToggle').textContent = enabled ? '🔊' : '🔇';
const e = SoundManager.toggle();
document.getElementById('soundToggle').textContent = e ? '🔊' : '🔇';
}
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('soundToggle');
if(btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
ctRenderInv(); ctRenderSel();
ctRenderInv();
ctRenderSel();
});
document.querySelectorAll('.modal-overlay').forEach(m => {
m.addEventListener('click', e => { if(e.target === m) m.style.display = 'none'; });
document.querySelectorAll('.ct-overlay').forEach(m => {
m.addEventListener('click', e => { if(e.target === m) m.classList.remove('open'); });
});
</script>
<script src="/static/js/sounds.js"></script>
<script src="/static/js/websocket.js"></script>
<script src="/static/js/safemode.js"></script>
{% if user %}{% include '_activity_sidebar.html' %}{% endif %}
</body>
</html>
+425 -107
View File
@@ -3,76 +3,132 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crash CS2 Simulator</title>
<title>Crash - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
#crashCanvas { width:100%; height:350px; display:block; border-radius:8px; }
.crash-multiplier { font-size:4rem; font-weight:800; text-shadow:0 0 40px rgba(34,197,94,0.5); transition:all 0.1s; }
.crash-multiplier.crashed { color:#ef4444; text-shadow:0 0 40px rgba(239,68,68,0.5); animation:shake 0.5s ease; }
.crash-multiplier.waiting { font-size:2rem; color:var(--text-dim); text-shadow:none; }
.crash-multiplier.betting { font-size:3rem; color:var(--primary); text-shadow:0 0 30px rgba(59,130,246,0.3); }
.quick-bet-btn { padding:0.4rem; background:var(--bg-dark); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); cursor:pointer; font-size:0.78rem; transition:all 0.2s; }
.quick-bet-btn:hover { border-color:var(--primary); }
.cashout-slider { width:100%; height:6px; background:var(--bg-dark); border-radius:3px; overflow:hidden; }
.cashout-slider-fill { height:100%; background:linear-gradient(90deg,var(--success),#f59e0b,#ef4444); border-radius:3px; transition:width 0.1s; }
.crash-layout { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 1rem; }
.crash-canvas-container {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1rem;
position: relative;
overflow: hidden;
}
#crashCanvas { width: 100%; height: 350px; display: block; border-radius: 8px; }
.crash-overlay {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
pointer-events: none;
}
.crash-multiplier { font-size: 4rem; font-weight: 800; text-shadow: 0 0 40px rgba(34,197,94,0.5); transition: all 0.1s; }
.crash-multiplier.crashed { color: #ef4444; text-shadow: 0 0 40px rgba(239,68,68,0.5); animation: crashShake 0.5s ease; }
.crash-multiplier.waiting { font-size: 2rem; color: var(--text-secondary); text-shadow: none; }
.crash-multiplier.betting { font-size: 3rem; color: var(--primary-color); text-shadow: 0 0 30px rgba(59,130,246,0.3); }
@keyframes crashShake {
0%,100%{transform:translateX(0)}10%{transform:translateX(-10px)rotate(-2deg)}30%{transform:translateX(10px)rotate(2deg)}50%{transform:translateX(-8px)rotate(-1deg)}70%{transform:translateX(8px)rotate(1deg)}90%{transform:translateX(-4px)}
}
.crash-status-text { font-size: 1rem; color: var(--text-secondary); margin-top: 0.5rem; }
.crash-controls { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.crash-bet-panel { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 1.5rem; }
.crash-players-panel { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 1.5rem; max-height: 300px; overflow-y: auto; }
.crash-bet-input { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.crash-bet-input input { flex: 1; padding: 0.75rem; background: var(--bg-dark); border: 1px solid var(--border-color); border-radius: 8px; color: var(--text-color); font-size: 1.1rem; }
.quick-bet-btns { display: grid; grid-template-columns: repeat(4,1fr); gap: 0.5rem; margin-bottom: 1rem; }
.quick-bet-btn { padding: 0.5rem; background: var(--bg-dark); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-color); cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
.quick-bet-btn:hover { border-color: var(--primary-color); }
.crash-btn { width: 100%; padding: 0.75rem; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; }
.crash-btn.bet { background: var(--primary-color); color: white; }
.crash-btn.bet:hover { filter: brightness(1.1); }
.crash-btn.bet.placed { background: #6366f1; }
.crash-btn.cashout { background: var(--success-color); color: white; }
.crash-btn.cashout:hover { filter: brightness(1.1); }
.crash-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.crash-btn.hidden { display: none; }
.player-row { display: flex; justify-content: space-between; align-items: center; padding: 0.4rem 0; border-bottom: 1px solid var(--border-color); font-size: 0.85rem; }
.player-row:last-child { border-bottom: none; }
.player-row .payout { color: var(--success-color); font-weight: 600; }
.player-row .bet-amount { color: var(--text-secondary); }
.crash-history { display: flex; gap: 0.4rem; flex-wrap: wrap; padding: 0.5rem 0; }
.crash-history-item { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
.crash-history-item.win { background: rgba(34,197,94,0.15); color: var(--success-color); }
.crash-history-item.loss { background: rgba(239,68,68,0.15); color: #ef4444; }
.cashout-progress { margin-top: 0.75rem; display: none; }
.cashout-progress.active { display: block; }
.cashout-slider { width: 100%; height: 6px; background: var(--bg-dark); border-radius: 3px; position: relative; overflow: hidden; }
.cashout-slider-fill { height: 100%; background: linear-gradient(90deg, var(--success-color), #f59e0b, #ef4444); border-radius: 3px; transition: width 0.1s; }
.cashout-label { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem; }
.crash-my-bet { margin-top: 0.5rem; padding: 0.5rem; background: var(--bg-dark); border-radius: 6px; text-align: center; font-size: 0.85rem; }
.crash-my-bet .bet-highlight { color: var(--primary-color); font-weight: 600; }
.crash-my-bet .win-highlight { color: var(--success-color); font-weight: 600; }
.crash-my-bet .lose-highlight { color: #ef4444; font-weight: 600; }
</style>
</head>
<body>
<nav class="navbar">
<div class="container">
<a href="/" class="logo">CS2 <span>Simulator</span></a>
<button class="mobile-toggle" id="mobileToggle"></button>
<div class="nav-links" id="navLinks">
<a href="/" class="logo">🎮 CS2 Simulator</a>
<div class="nav-links">
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</a>
<a href="/crash" class="nav-link active">Crash</a>
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
<a href="/crash" class="nav-link active">💥 Crash</a>
<a href="/inventory" class="nav-link">Инвентарь</a>
<a href="/achievements" class="nav-link">Достижения</a>
<a href="/activity" class="nav-link">Лента</a>
<a href="/achievements" class="nav-link">🏆 Достижения</a>
<a href="/activity" class="nav-link">📰 Лента</a>
<a href="/profile" class="nav-link">Профиль</a>
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-ghost">Выйти</button>
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle">🔊</button>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle">🎙️</button>
<button onclick="logout()" class="btn btn-outline">Выйти</button>
</div>
</div>
</nav>
<main class="section">
<div class="container" style="max-width:960px;">
<div class="card" style="position:relative;overflow:hidden;">
<main class="container" style="padding: 2rem 1rem;">
<div class="crash-layout">
<div class="crash-canvas-container">
<canvas id="crashCanvas"></canvas>
<div style="position:absolute;top:0;left:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;">
<div class="crash-overlay">
<div class="crash-multiplier waiting" id="crashMultiplier">Ожидание...</div>
<div class="text-dim text-sm" style="margin-top:0.5rem;" id="crashStatus">Следующий раунд скоро начнётся</div>
<div class="crash-status-text" id="crashStatus">Следующий раунд скоро начнётся</div>
</div>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;padding:0.5rem 0;" id="crashHistory"></div>
<div class="crash-history" id="crashHistory"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
<div class="card">
<h3 class="display-font" style="font-size:0.85rem;margin-bottom:0.75rem;">💰 Сделать ставку</h3>
<div class="flex gap-2" style="margin-bottom:0.75rem;">
<input type="number" id="betAmount" value="100" min="1" step="1" class="form-input" style="flex:1;">
<div class="crash-controls">
<div class="crash-bet-panel">
<h3>💰 Сделать ставку</h3>
<div class="crash-bet-input">
<input type="number" id="betAmount" value="100" min="1" step="1">
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.35rem;margin-bottom:0.75rem;">
<div class="quick-bet-btns">
<button class="quick-bet-btn" onclick="setBet(50)">50</button>
<button class="quick-bet-btn" onclick="setBet(100)">100</button>
<button class="quick-bet-btn" onclick="setBet(500)">500</button>
<button class="quick-bet-btn" onclick="setBet(1000)">1K</button>
</div>
<button class="btn btn-primary btn-block" id="betBtn" onclick="placeBet()">💰 Сделать ставку</button>
<button class="btn btn-success btn-block" id="cashoutBtn" onclick="cashOut()" style="display:none;">💰 Забрать</button>
<div id="myBetDisplay" style="display:none;margin-top:0.5rem;padding:0.4rem;background:var(--bg-dark);border-radius:var(--radius-sm);text-align:center;font-size:0.85rem;"></div>
<div class="cashout-progress" id="cashoutProgress" style="display:none;margin-top:0.5rem;">
<div class="cashout-slider"><div class="cashout-slider-fill" id="cashoutSliderFill" style="width:0%"></div></div>
<div class="flex justify-between text-xs text-dim" style="margin-top:0.2rem;"><span>1.00x</span><span id="cashoutMultLabel">1.00x</span></div>
<button class="crash-btn bet" id="betBtn" onclick="placeBet()">Сделать ставку</button>
<button class="crash-btn cashout hidden" id="cashoutBtn" onclick="cashOut()">💰 Забрать</button>
<div id="myBetDisplay" class="crash-my-bet" style="display:none;"></div>
<div class="cashout-progress" id="cashoutProgress">
<div class="cashout-slider">
<div class="cashout-slider-fill" id="cashoutSliderFill" style="width:0%"></div>
</div>
<div class="cashout-label">
<span>1.00x</span>
<span id="cashoutMultLabel">1.00x</span>
</div>
</div>
</div>
<div class="card">
<h3 class="display-font" style="font-size:0.85rem;margin-bottom:0.75rem;">👥 Ставки</h3>
<div id="playersList"><div class="text-dim text-sm text-center" style="padding:1rem;">Ожидание ставок...</div></div>
<div class="crash-players-panel">
<h3>👥 Ставки</h3>
<div id="playersList">
<div style="color: var(--text-secondary); text-align: center; padding: 1rem;">
Ожидание ставок...
</div>
</div>
</div>
</div>
</div>
@@ -82,118 +138,380 @@
<script src="/static/js/websocket.js"></script>
<script src="/static/js/safemode.js"></script>
<script>
const canvas = document.getElementById('crashCanvas'), ctx = canvas.getContext('2d');
const multiplierEl = document.getElementById('crashMultiplier'), statusEl = document.getElementById('crashStatus');
const betBtn = document.getElementById('betBtn'), cashoutBtn = document.getElementById('cashoutBtn');
const playersList = document.getElementById('playersList'), historyEl = document.getElementById('crashHistory');
const betInput = document.getElementById('betAmount'), myBetDisplay = document.getElementById('myBetDisplay');
const cashoutProgress = document.getElementById('cashoutProgress'), cashoutSliderFill = document.getElementById('cashoutSliderFill'), cashoutMultLabel = document.getElementById('cashoutMultLabel');
let gameState = null, myActiveBet = null, myCashoutMult = null, lastPhase = null;
const canvas = document.getElementById('crashCanvas');
const ctx = canvas.getContext('2d');
const multiplierEl = document.getElementById('crashMultiplier');
const statusEl = document.getElementById('crashStatus');
const betBtn = document.getElementById('betBtn');
const cashoutBtn = document.getElementById('cashoutBtn');
const playersList = document.getElementById('playersList');
const historyEl = document.getElementById('crashHistory');
const betInput = document.getElementById('betAmount');
const myBetDisplay = document.getElementById('myBetDisplay');
const cashoutProgress = document.getElementById('cashoutProgress');
const cashoutSliderFill = document.getElementById('cashoutSliderFill');
const cashoutMultLabel = document.getElementById('cashoutMultLabel');
function resizeCanvas() { const r = canvas.parentElement.getBoundingClientRect(); canvas.width = canvas.clientWidth || r.width-32; canvas.height = canvas.clientHeight || 350; }
resizeCanvas(); window.addEventListener('resize', resizeCanvas);
function setBet(a) { betInput.value = a; SoundManager.click(); }
function expMult(t) { return Math.exp(t*0.08); }
function timeForMult(m) { return Math.log(m)/0.08; }
let gameState = null;
let myActiveBet = null; // текущая ставка в этом раунде
let myCashoutMult = null; // на каком множителе вывел (null = не вывел)
let lastPhase = null;
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = canvas.clientWidth || rect.width - 32;
canvas.height = canvas.clientHeight || 350;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
function setBet(amount) { betInput.value = amount; SoundManager.click(); }
function expMult(t) { return Math.exp(t * 0.08); }
function timeForMult(m) { return Math.log(m) / 0.08; }
let graphPoints = [];
const GRAPH_WINDOW = 15;
const GRAPH_WINDOW = 15; // секунд в окне
function drawGraph() {
const w = canvas.width, h = canvas.height, pad = 40, drawW = w-pad*2, drawH = h-pad*2;
ctx.clearRect(0,0,w,h);
if (!gameState || gameState.phase === 'waiting') { ctx.fillStyle = 'rgba(255,255,255,0.08)'; ctx.font = '20px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('💥 Crash', w/2, h/2); return; }
const phase = gameState.phase, curMult = gameState.current_multiplier||1, crashPoint = gameState.crash_point||100, isCrashed = phase === 'crashed', isRunning = phase === 'running';
if (isRunning && curMult > 1) { const now = performance.now()/1000; graphPoints.push({t:now, m:curMult}); const cutoff = now - GRAPH_WINDOW; graphPoints = graphPoints.filter(p => p.t >= cutoff); if (graphPoints.length > 500) graphPoints = graphPoints.slice(-500); }
else if (isCrashed) graphPoints = [];
let maxY = 2; if (graphPoints.length > 0) maxY = Math.max(...graphPoints.map(p=>p.m)); maxY = Math.max(maxY, curMult+1, crashPoint, 3); maxY = Math.min(maxY, 200); maxY = Math.ceil(maxY);
ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1; ctx.font = '10px sans-serif'; ctx.textAlign = 'right';
for (let i = 1; i <= 5; i++) { const y = h-pad-(i/5)*drawH; ctx.beginPath(); ctx.moveTo(pad,y); ctx.lineTo(w-pad,y); ctx.stroke(); const multLabel = 1+(maxY-1)*(i/5); ctx.fillStyle = 'rgba(255,255,255,0.12)'; ctx.fillText(`x${multLabel.toFixed(1)}`, pad-6, y+3); }
if (graphPoints.length < 2) { if (isCrashed) { ctx.fillStyle = 'rgba(239,68,68,0.5)'; ctx.font = '36px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(`💥 x${crashPoint.toFixed(2)}`, w/2, h/2); } return; }
const color = isCrashed ? '#ef4444' : '#22c55e'; const now = performance.now()/1000, windowStart = now - GRAPH_WINDOW;
ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 3; ctx.shadowColor = color; ctx.shadowBlur = 10;
const w = canvas.width, h = canvas.height, pad = 40;
const drawW = w - pad * 2, drawH = h - pad * 2;
ctx.clearRect(0, 0, w, h);
if (!gameState || gameState.phase === 'waiting') {
ctx.fillStyle = 'rgba(255,255,255,0.08)';
ctx.font = '20px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('💥 Crash', w/2, h/2);
return;
}
const phase = gameState.phase;
const curMult = gameState.current_multiplier || 1;
const crashPoint = gameState.crash_point || 100;
const isCrashed = phase === 'crashed';
const isRunning = phase === 'running';
// Добавляем текущую точку в историю
if (isRunning && curMult > 1) {
const now = performance.now() / 1000;
graphPoints.push({ t: now, m: curMult });
// Отсекаем точки за пределами окна
const cutoff = now - GRAPH_WINDOW;
graphPoints = graphPoints.filter(p => p.t >= cutoff);
if (graphPoints.length > 500) graphPoints = graphPoints.slice(-500);
} else if (isCrashed) {
graphPoints = [];
}
// Определяем диапазон мультипликаторов для оси Y
let maxY = 2;
if (graphPoints.length > 0) {
maxY = Math.max(...graphPoints.map(p => p.m));
}
maxY = Math.max(maxY, curMult + 1, crashPoint, 3);
maxY = Math.min(maxY, 200); // не растягивать до небес
maxY = Math.ceil(maxY);
// Сетка
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
const gridLines = 5;
ctx.font = '10px sans-serif';
ctx.textAlign = 'right';
for (let i = 1; i <= gridLines; i++) {
const y = h - pad - (i / gridLines) * drawH;
ctx.beginPath(); ctx.moveTo(pad, y); ctx.lineTo(w - pad, y); ctx.stroke();
const multLabel = 1 + (maxY - 1) * (i / gridLines);
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.fillText(`x${multLabel.toFixed(1)}`, pad - 6, y + 3);
}
// Рисуем кривую
if (graphPoints.length < 2) {
if (isCrashed) {
ctx.fillStyle = 'rgba(239,68,68,0.5)';
ctx.font = '36px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`💥 x${crashPoint.toFixed(2)}`, w/2, h/2);
}
return;
}
const color = isCrashed ? '#ef4444' : '#22c55e';
const now = performance.now() / 1000;
const windowStart = now - GRAPH_WINDOW;
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.shadowColor = color;
ctx.shadowBlur = 10;
let first = true;
for (const p of graphPoints) { const x = pad+((p.t-windowStart)/GRAPH_WINDOW)*drawW, y = h-pad-((p.m-1)/(maxY-1))*drawH; if (first) { ctx.moveTo(x,y); first=false; } else ctx.lineTo(x,y); }
ctx.stroke(); ctx.shadowBlur = 0;
if (isRunning && curMult > 1) { const lastPt = graphPoints[graphPoints.length-1]; const x = pad+((lastPt.t-windowStart)/GRAPH_WINDOW)*drawW, y = h-pad-((lastPt.m-1)/(maxY-1))*drawH; ctx.beginPath(); ctx.arc(x,y,5,0,Math.PI*2); ctx.fillStyle = '#22c55e'; ctx.fill(); }
if (phase === 'betting') { ctx.fillStyle = 'rgba(255,255,255,0.06)'; ctx.font = '40px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(`⏱ ${gameState.timer}`, w/2, h/2+10); }
for (const p of graphPoints) {
const x = pad + ((p.t - windowStart) / GRAPH_WINDOW) * drawW;
const y = h - pad - ((p.m - 1) / (maxY - 1)) * drawH;
if (first) { ctx.moveTo(x, y); first = false; }
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
// Текущая точка (справа, на последнем значении)
if (isRunning && curMult > 1) {
const lastPt = graphPoints[graphPoints.length - 1];
const x = pad + ((lastPt.t - windowStart) / GRAPH_WINDOW) * drawW;
const y = h - pad - ((lastPt.m - 1) / (maxY - 1)) * drawH;
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fillStyle = '#22c55e';
ctx.fill();
ctx.shadowColor = '#22c55e';
ctx.shadowBlur = 20;
ctx.beginPath();
ctx.arc(x, y, 8, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(34,197,94,0.4)';
ctx.lineWidth = 2;
ctx.stroke();
ctx.shadowBlur = 0;
}
// Беттинг фаза — таймер
if (phase === 'betting') {
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.font = '40px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`⏱ ${gameState.timer}`, w/2, h/2 + 10);
}
}
function updateUI() {
if (!gameState) return;
const phase = gameState.phase;
if (phase === 'waiting') { multiplierEl.className = 'crash-multiplier waiting'; multiplierEl.textContent = 'Ожидание...'; statusEl.textContent = 'Следующий раунд скоро начнётся'; }
else if (phase === 'betting') { multiplierEl.className = 'crash-multiplier betting'; multiplierEl.textContent = `${gameState.timer}`; statusEl.textContent = 'Делайте ставки!'; }
else if (phase === 'running') { const mult = gameState.current_multiplier; multiplierEl.className = 'crash-multiplier'; multiplierEl.textContent = `x${mult.toFixed(2)}`; statusEl.textContent = '🚀 Взлетаем!'; if (myActiveBet && myCashoutMult === null) { cashoutSliderFill.style.width = `${Math.min(100,((mult-1)/15)*100)}%`; cashoutMultLabel.textContent = `x${mult.toFixed(2)}`; } }
else if (phase === 'crashed') { multiplierEl.className = 'crash-multiplier crashed'; multiplierEl.textContent = `💥 x${gameState.crash_point.toFixed(2)}`; statusEl.textContent = 'Краш!'; }
const canBet = phase === 'betting', canCashout = phase === 'running' && myActiveBet !== null && myCashoutMult === null;
if (canBet) { betBtn.style.display = ''; betBtn.disabled = false; betBtn.textContent = myActiveBet ? `Ставка: ${myActiveBet} ₽` : '💰 Сделать ставку'; betBtn.className = 'btn btn-primary btn-block'; if (myActiveBet) betBtn.className = 'btn btn-outline btn-block'; }
else betBtn.style.display = canCashout ? 'none' : '';
cashoutBtn.style.display = canCashout ? '' : 'none';
if (canCashout) cashoutBtn.textContent = `💰 Забрать (x${gameState.current_multiplier.toFixed(2)})`;
cashoutProgress.style.display = (phase === 'running' && myActiveBet !== null && myCashoutMult === null) ? 'block' : 'none';
// Обновляем множитель и статус
if (phase === 'waiting') {
multiplierEl.className = 'crash-multiplier waiting';
multiplierEl.textContent = 'Ожидание...';
statusEl.textContent = 'Следующий раунд скоро начнётся';
} else if (phase === 'betting') {
multiplierEl.className = 'crash-multiplier betting';
multiplierEl.textContent = `${gameState.timer}`;
statusEl.textContent = 'Делайте ставки!';
} else if (phase === 'running') {
const mult = gameState.current_multiplier;
multiplierEl.className = 'crash-multiplier';
multiplierEl.textContent = `x${mult.toFixed(2)}`;
statusEl.textContent = '🚀 Взлетаем!';
if (myActiveBet && myCashoutMult === null) {
const prog = Math.min(100, ((mult - 1) / 15) * 100);
cashoutSliderFill.style.width = `${prog}%`;
cashoutMultLabel.textContent = `x${mult.toFixed(2)}`;
}
} else if (phase === 'crashed') {
multiplierEl.className = 'crash-multiplier crashed';
multiplierEl.textContent = `💥 x${gameState.crash_point.toFixed(2)}`;
statusEl.textContent = 'Краш!';
}
// Кнопки
const canBet = phase === 'betting';
const canCashout = phase === 'running' && myActiveBet !== null && myCashoutMult === null;
betBtn.classList.toggle('hidden', !canBet && !(phase === 'betting' && myActiveBet));
if (canBet) {
betBtn.disabled = false;
betBtn.textContent = myActiveBet ? `Ставка: ${myActiveBet} ₽` : '💰 Сделать ставку';
betBtn.className = 'crash-btn bet' + (myActiveBet ? ' placed' : '');
}
cashoutBtn.classList.toggle('hidden', !canCashout);
if (canCashout) {
cashoutBtn.disabled = false;
cashoutBtn.textContent = `💰 Забрать (x${gameState.current_multiplier.toFixed(2)})`;
}
cashoutProgress.classList.toggle('active', phase === 'running' && myActiveBet !== null && myCashoutMult === null);
// Статус моей ставки
if (myActiveBet !== null) {
myBetDisplay.style.display = 'block';
if (myCashoutMult !== null) myBetDisplay.innerHTML = `✅ Вывел(а) <span style="color:var(--success);font-weight:600;">${(myActiveBet*myCashoutMult).toFixed(0)} ₽</span> на x${myCashoutMult.toFixed(2)}`;
else if (phase === 'crashed') myBetDisplay.innerHTML = `❌ Проиграно <span style="color:#ef4444;font-weight:600;">${myActiveBet.toFixed(0)} ₽</span>`;
else if (phase === 'running') myBetDisplay.innerHTML = `Ставка: <span style="color:var(--primary);font-weight:600;">${myActiveBet.toFixed(0)} ₽</span> → ${(myActiveBet*gameState.current_multiplier).toFixed(0)} ₽`;
else myBetDisplay.innerHTML = `Ставка: <span style="color:var(--primary);font-weight:600;">${myActiveBet.toFixed(0)} ₽</span>`;
} else myBetDisplay.style.display = 'none';
if (myCashoutMult !== null) {
const payout = myActiveBet * myCashoutMult;
myBetDisplay.style.display = 'block';
myBetDisplay.innerHTML = `
✅ Вывел(а) <span class="win-highlight">${payout.toFixed(0)} ₽</span>
на x${myCashoutMult.toFixed(2)}
`;
} else if (phase === 'crashed') {
myBetDisplay.style.display = 'block';
myBetDisplay.innerHTML = `
❌ Проиграно <span class="lose-highlight">${myActiveBet.toFixed(0)} ₽</span>
`;
} else if (phase === 'running') {
const potential = myActiveBet * gameState.current_multiplier;
myBetDisplay.style.display = 'block';
myBetDisplay.innerHTML = `
Ставка: <span class="bet-highlight">${myActiveBet.toFixed(0)} ₽</span>
→ ${potential.toFixed(0)} ₽
`;
} else {
myBetDisplay.style.display = 'block';
myBetDisplay.innerHTML = `
Ставка: <span class="bet-highlight">${myActiveBet.toFixed(0)} ₽</span>
`;
}
} else {
myBetDisplay.style.display = 'none';
}
// Звуки на смену фазы
if (lastPhase !== phase) {
if (phase === 'running') SoundManager.crashBet();
if (phase === 'crashed') { SoundManager.crashCrashed(); myActiveBet = null; myCashoutMult = null; }
if (phase === 'betting' && (lastPhase === 'crashed' || lastPhase === 'waiting')) { myActiveBet = null; myCashoutMult = null; }
if (phase === 'crashed') SoundManager.crashCrashed();
lastPhase = phase;
}
updatePlayers(); updateHistory(); drawGraph();
updatePlayers();
updateHistory();
drawGraph();
}
function updatePlayers() {
if (!gameState) return;
const bc = gameState.bet_count||0;
if (bc === 0) { playersList.innerHTML = '<div class="text-dim text-sm text-center" style="padding:1rem;">Пока нет ставок</div>'; return; }
const cashed = gameState.cashed_out||{};
let html = `<div class="flex justify-between text-xs" style="font-weight:600;padding:0.3rem 0;border-bottom:1px solid var(--border);"><span>Ставок: ${bc}</span><span>Сумма: ${(gameState.total_bets||0).toFixed(0)} ₽</span><span>Вывели: ${Object.keys(cashed).length}</span></div>`;
const bc = gameState.bet_count || 0;
if (bc === 0) {
playersList.innerHTML = '<div style="color:var(--text-secondary);text-align:center;padding:1rem;">Пока нет ставок</div>';
return;
}
const cashed = gameState.cashed_out || {};
const cashCount = Object.keys(cashed).length;
let html = `<div class="player-row" style="font-weight:600;border-bottom:2px solid var(--border-color);">
<span>Ставок: ${bc}</span>
<span>Сумма: ${(gameState.total_bets || 0).toFixed(0)} ₽</span>
<span>Вывели: ${cashCount}</span>
</div>`;
if (gameState.phase === 'running' || gameState.phase === 'crashed') {
const mult = gameState.current_multiplier || gameState.crash_point;
for (const [uid, m] of Object.entries(cashed)) {
const isMe = myActiveBet !== null && parseInt(uid) === 0;
html += `<div class="flex justify-between text-xs" style="padding:0.25rem 0;${isMe?'color:var(--success)':''}"><span>${isMe?'Вы':'Игрок #'+uid.slice(-4)}</span><span style="color:var(--success);font-weight:600;">✅ x${m.toFixed(2)}</span></div>`;
html += `<div class="player-row" style="${isMe ? 'color:var(--success-color)' : ''}">
<span>${isMe ? 'Вы' : 'Игрок #' + uid.slice(-4)}</span>
<span class="payout">✅ x${m.toFixed(2)}</span>
</div>`;
}
}
playersList.innerHTML = html;
}
function updateHistory() {
if (!gameState || !gameState.history) return;
historyEl.innerHTML = gameState.history.slice().reverse().map(m => `<span style="padding:0.15rem 0.45rem;border-radius:4px;font-size:0.7rem;font-weight:600;${m>2?'background:rgba(34,197,94,0.15);color:var(--success)':'background:rgba(239,68,68,0.15);color:#ef4444'}">x${m.toFixed(2)}</span>`).join('');
historyEl.innerHTML = gameState.history.slice().reverse().map(m => {
const isWin = m > 2;
return `<span class="crash-history-item ${isWin ? 'win' : 'loss'}">x${m.toFixed(2)}</span>`;
}).join('');
}
WS.on('crash_state', (data) => { if (!data.game) return; gameState = data.game; updateUI(); });
// WebSocket events
WS.on('crash_state', (data) => {
if (!data.game) return;
gameState = data.game;
// При краше сбрасываем мою ставку т.к. раунд закончился
if (gameState.phase === 'crashed' && lastPhase === 'running') {
// Если не вывел и краш — ставка проиграна, но стейт остается
}
if (gameState.phase === 'waiting' || gameState.phase === 'betting') {
// Новый раунд — если у меня была ставка, её уже нет (проиграна/выведена)
if (lastPhase === 'crashed' || lastPhase === 'running') {
if (myCashoutMult === null && myActiveBet !== null && gameState.phase === 'betting') {
// Ставка проиграна в прошлом раунде
}
}
if (gameState.phase === 'betting') {
// Если начался новый раунд betting и мы не обнулились —
// это новый раунд. Старая ставка аннулируется.
if (lastPhase === 'crashed' || lastPhase === 'waiting') {
myActiveBet = null;
myCashoutMult = null;
}
}
}
updateUI();
});
async function placeBet() {
const amount = parseInt(betInput.value);
if (!amount || amount <= 0) { SoundManager.error(); return; }
const fd = new FormData(); fd.append('amount', amount);
if (!amount || amount <= 0) {
SoundManager.error();
return;
}
const formData = new FormData();
formData.append('amount', amount);
try {
const res = await fetch('/web/api/crash/bet', { method:'POST', body:fd });
const res = await fetch('/web/api/crash/bet', { method: 'POST', body: formData });
const data = await res.json();
if (data.success) { myActiveBet = data.bet; myCashoutMult = null; SoundManager.crashBet(); document.querySelectorAll('.user-balance').forEach(el => el.textContent = `${Math.floor(data.new_balance)} ₽`); updateUI(); }
else { SoundManager.error(); alert(data.error); }
} catch(e) { SoundManager.error(); alert('Ошибка соединения'); }
if (data.success) {
myActiveBet = data.bet;
myCashoutMult = null;
SoundManager.crashBet();
document.querySelectorAll('.user-balance').forEach(el => {
el.textContent = `${Math.floor(data.new_balance)} ₽`;
});
updateUI();
} else {
SoundManager.error();
alert(data.error);
}
} catch (e) {
SoundManager.error();
alert('Ошибка соединения');
}
}
async function cashOut() {
if (!myActiveBet || myCashoutMult !== null) return;
try {
const res = await fetch('/web/api/crash/cashout', { method:'POST' });
const res = await fetch('/web/api/crash/cashout', { method: 'POST' });
const data = await res.json();
if (data.success) { myCashoutMult = data.multiplier; SoundManager.crashCashout(myCashoutMult); document.querySelectorAll('.user-balance').forEach(el => el.textContent = `${Math.floor(data.new_balance)} ₽`); updateUI(); }
else { SoundManager.error(); alert(data.error); }
} catch(e) { SoundManager.error(); alert('Ошибка соединения'); }
if (data.success) {
myCashoutMult = data.multiplier;
SoundManager.crashCashout();
document.querySelectorAll('.user-balance').forEach(el => {
el.textContent = `${Math.floor(data.new_balance)} ₽`;
});
updateUI();
} else {
SoundManager.error();
alert(data.error);
}
} catch (e) {
SoundManager.error();
alert('Ошибка соединения');
}
}
function toggleSound() { const e = SoundManager.toggle(); document.getElementById('soundToggle').textContent = e ? '🔊' : '🔇'; }
document.addEventListener('DOMContentLoaded', () => { const btn = document.getElementById('soundToggle'); if(btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇'; });
async function logout() {
await fetch('/web/api/auth/logout', { method: 'POST' });
window.location.href = '/';
}
function toggleSound() {
const enabled = SoundManager.toggle();
document.getElementById('soundToggle').textContent = enabled ? '🔊' : '🔇';
}
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('soundToggle');
if (btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
});
</script>
{% include '_activity_sidebar.html' %}
</body>
+23 -22
View File
@@ -9,49 +9,50 @@
<body>
<nav class="navbar">
<div class="container">
<a href="/" class="logo">CS2 <span>Simulator</span></a>
<button class="mobile-toggle" id="mobileToggle"></button>
<div class="nav-links" id="navLinks">
<a href="/" class="logo">🎮 CS2 Simulator</a>
<div class="nav-links">
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</a>
<a href="/activity" class="nav-link">Лента</a>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
<a href="/activity" class="nav-link">📰 Лента</a>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/login" class="btn btn-outline">Войти</a>
<a href="/register" class="btn btn-primary">Регистрация</a>
</div>
</div>
</nav>
<section class="hero">
<main class="hero">
<div class="container">
<h1>CS2 Trade-Up Simulator</h1>
<p class="hero-subtitle">Открывай кейсы, собирай скины и создавай контракты</p>
<div class="grid grid-3" style="max-width:750px;margin:0 auto 2.5rem;">
<div class="card card-hover" style="text-align:center;padding:1.5rem 1rem;">
<div style="font-size:2rem;margin-bottom:0.5rem;" class="display-font">📦</div>
<h3 style="font-family:var(--font-display);font-size:0.95rem;margin-bottom:0.3rem;">Открывай кейсы</h3>
<p class="text-dim text-sm">Испытай удачу и получи редкие скины</p>
<div class="features">
<div class="feature-card">
<div class="feature-icon">📦</div>
<h3>Открывай кейсы</h3>
<p>Испытай удачу и получи редкие скины</p>
</div>
<div class="card card-hover" style="text-align:center;padding:1.5rem 1rem;">
<div style="font-size:2rem;margin-bottom:0.5rem;" class="display-font">🔄</div>
<h3 style="font-family:var(--font-display);font-size:0.95rem;margin-bottom:0.3rem;">Создавай контракты</h3>
<p class="text-dim text-sm">Обменивай 10 скинов на один более редкий</p>
<div class="feature-card">
<div class="feature-icon">🔄</div>
<h3>Создавай контракты</h3>
<p>Обменивай 10 скинов на один более редкий</p>
</div>
<div class="card card-hover" style="text-align:center;padding:1.5rem 1rem;">
<div style="font-size:2rem;margin-bottom:0.5rem;" class="display-font">📊</div>
<h3 style="font-family:var(--font-display);font-size:0.95rem;margin-bottom:0.3rem;">Собирай коллекцию</h3>
<p class="text-dim text-sm">Отслеживай свой прогресс и статистику</p>
<div class="feature-card">
<div class="feature-icon">📊</div>
<h3>Собирай коллекцию</h3>
<p>Отслеживай свой прогресс и статистику</p>
</div>
</div>
<div class="hero-actions">
<a href="/register" class="btn btn-primary btn-large">Начать играть</a>
<a href="/login" class="btn btn-outline btn-large">Уже есть аккаунт</a>
</div>
</div>
</section>
</main>
<script src="/static/js/app.js"></script>
<script src="/static/js/safemode.js"></script>
</body>
</html>
</html>
+579 -202
View File
@@ -3,70 +3,276 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Инвентарь CS2 Simulator</title>
<title>Инвентарь - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.inventory-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 1rem;
}
.selection-controls {
display: flex;
align-items: center;
gap: 0.75rem;
}
.select-all-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.select-all-checkbox input {
width: 18px;
height: 18px;
cursor: pointer;
}
.sell-selected-btn {
padding: 0.5rem 1.5rem;
background: var(--danger-color);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.sell-selected-btn:hover {
background: #dc2626;
transform: translateY(-1px);
}
.sell-selected-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sell-all-btn {
padding: 0.5rem 1.5rem;
background: transparent;
color: var(--danger-color);
border: 1px solid var(--danger-color);
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.sell-all-btn:hover {
background: rgba(239, 68, 68, 0.1);
}
.inventory-skin-card {
position: relative;
}
.item-select-checkbox {
position: absolute;
top: 8px;
left: 8px;
width: 20px;
height: 20px;
cursor: pointer;
z-index: 10;
accent-color: var(--primary-color);
}
.inventory-skin-card.selected-for-sale {
border-color: var(--danger-color);
box-shadow: 0 0 15px rgba(239, 68, 68, 0.3);
}
.selected-count-badge {
background: var(--danger-color);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.total-price-badge {
background: var(--success-color);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.item-actions {
display: flex;
gap: 0.5rem;
margin-top: 8px;
}
.btn-sell-single {
flex: 1;
padding: 0.25rem;
background: transparent;
border: 1px solid var(--danger-color);
color: var(--danger-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s;
}
.btn-sell-single:hover {
background: var(--danger-color);
color: white;
}
.filter-bar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.bulk-sell-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.bulk-sell-content {
background: var(--bg-card);
padding: 2rem;
border-radius: 12px;
max-width: 400px;
width: 90%;
}
.bulk-sell-content h3 {
margin-bottom: 1rem;
}
.bulk-sell-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 1.5rem 0;
}
.bulk-sell-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-dark);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.bulk-sell-option:hover {
background: var(--bg-light);
}
.bulk-sell-option input {
width: 18px;
height: 18px;
}
.bulk-sell-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="container">
<a href="/" class="logo">CS2 <span>Simulator</span></a>
<button class="mobile-toggle" id="mobileToggle"></button>
<div class="nav-links" id="navLinks">
<a href="/" class="logo">🎮 CS2 Simulator</a>
<div class="nav-links">
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</a>
<a href="/crash" class="nav-link">Crash</a>
<a href="/inventory" class="nav-link active">Инвентарь</a>
<a href="/activity" class="nav-link">Лента</a>
<a href="/achievements" class="nav-link">Достижения</a>
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
<a href="/crash" class="nav-link">💥 Crash</a>
<a href="/inventory" class="nav-link">Инвентарь</a>
<a href="/activity" class="nav-link">📰 Лента</a>
<a href="/achievements" class="nav-link">🏆 Достижения</a>
<a href="/profile" class="nav-link">Профиль</a>
<span class="user-balance">{{ "%.2f"|format(user.balance) }} ₽</span>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-ghost">Выйти</button>
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-outline">Выйти</button>
</div>
</div>
</nav>
<main class="section">
<main class="inventory-container">
<div class="container">
<div class="section-header">
<div class="inventory-header">
<div>
<h1 class="section-title">🎒 Инвентарь</h1>
<p class="section-subtitle">Всего предметов: <span id="totalItemsCount">{{ inventory_items|length }}</span></p>
<h1>🎒 Инвентарь</h1>
<p class="page-subtitle">Всего предметов: <span id="totalItemsCount">{{ inventory_items|length }}</span></p>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;">
<label class="form-checkbox">
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()">
<span class="text-sm">Все</span>
</label>
<span class="badge badge-red" id="selectedCountBadge" style="display:none;">0</span>
<span class="badge badge-green" id="totalPriceBadge" style="display:none;">0 ₽</span>
<button class="btn btn-danger btn-sm" id="sellSelectedBtn" onclick="sellSelected()" disabled>Продать</button>
<button class="btn btn-outline btn-sm" onclick="openBulkSellModal()">Массовая продажа</button>
<div class="inventory-actions">
<div class="selection-controls">
<label class="select-all-checkbox">
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()">
<span>Выбрать все</span>
</label>
<span class="selected-count-badge" id="selectedCountBadge" style="display: none;">0</span>
<span class="total-price-badge" id="totalPriceBadge" style="display: none;">0 ₽</span>
</div>
<div style="display: flex; gap: 0.75rem;">
<button class="sell-selected-btn" id="sellSelectedBtn" onclick="sellSelected()" disabled>
💰 Продать выбранные
</button>
<button class="sell-all-btn" onclick="openBulkSellModal()">
📦 Массовая продажа
</button>
</div>
</div>
</div>
<div class="filter-bar">
<select id="rarityFilter" class="form-select" style="width:auto;min-width:140px;" onchange="filterItems()">
<option value="">Все редкости</option>
{% for rarity in rarities %}
<option value="{{ rarity }}">{{ rarity }}</option>
{% endfor %}
</select>
<select id="typeFilter" class="form-select" style="width:auto;min-width:120px;" onchange="filterItems()">
<option value="">Все типы</option>
{% for type in types %}
<option value="{{ type }}">{{ type }}</option>
{% endfor %}
</select>
<input type="text" id="searchInput" class="form-input" placeholder="Поиск..." oninput="filterItems()" style="min-width:160px;">
<button onclick="resetFilters()" class="btn btn-ghost btn-sm">Сбросить</button>
<div class="inventory-filters">
<div class="filter-bar">
<select id="rarityFilter" class="filter-select" onchange="filterItems()">
<option value="">Все редкости</option>
{% for rarity in rarities %}
<option value="{{ rarity }}">{{ rarity }}</option>
{% endfor %}
</select>
<select id="typeFilter" class="filter-select" onchange="filterItems()">
<option value="">Все типы</option>
{% for type in types %}
<option value="{{ type }}">{{ type }}</option>
{% endfor %}
</select>
<input type="text" id="searchInput" class="filter-select"
placeholder="Поиск по названию..." oninput="filterItems()">
<button onclick="resetFilters()" class="btn btn-outline">Сбросить</button>
</div>
</div>
{% if inventory_items %}
<div class="grid grid-auto-sm" id="inventoryGrid">
<div class="inventory-grid-detailed" id="inventoryGrid">
{% for item in inventory_items %}
<div class="card" style="position:relative;padding:0.85rem;"
<div class="inventory-skin-card rarity-{{ item.rarity|lower|replace(' ', '-') }}"
data-inventory-id="{{ item.id }}"
data-rarity="{{ item.rarity }}"
data-type="{{ item.type }}"
@@ -75,55 +281,67 @@
<input type="checkbox" class="item-select-checkbox"
onchange="onItemSelect(this)"
data-id="{{ item.id }}"
data-price="{{ item.price_rub or 100 }}"
style="position:absolute;top:8px;left:8px;width:18px;height:18px;accent-color:var(--primary);z-index:2;cursor:pointer;">
<div style="width:100%;height:90px;display:flex;align-items:center;justify-content:center;background:var(--bg-dark);border-radius:var(--radius-sm);margin-bottom:0.5rem;">
<img src="{{ item.image_url or '/static/placeholder.png' }}" alt="{{ item.name }}"
style="max-width:85%;max-height:80px;object-fit:contain;"
data-price="{{ item.price_rub or 100 }}">
<div class="skin-image-container">
<img src="{{ item.image_url or '/static/placeholder.png' }}"
alt="{{ item.name }}"
class="skin-image"
onerror="this.src='/static/placeholder.png'">
</div>
<div class="text-sm truncate" title="{{ item.name }}">{{ item.name }}</div>
<div class="text-xs text-dim" style="margin-top:0.15rem;">
<span>{{ item.rarity }}</span>
<span>Float: {{ "%.6f"|format(item.float) }}</span>
</div>
<div class="text-xs text-dim">{{ item.type }} / {{ item.wear }}</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:0.5rem;">
<span style="color:var(--primary);font-weight:600;font-size:0.85rem;">💰 {{ "%.0f"|format(item.price_rub or 100) }}</span>
<button onclick="sellSingleItem({{ item.id }})" class="btn btn-ghost btn-sm" style="color:var(--danger);">Продать</button>
<div class="skin-info">
<div class="skin-name" title="{{ item.name }}">{{ item.name }}</div>
<div class="skin-details">
<span class="rarity-{{ item.rarity|lower|replace(' ', '-') }}">{{ item.rarity }}</span>
<span>Float: {{ "%.6f"|format(item.float) }}</span>
</div>
<div class="skin-details" style="margin-top: 5px;">
<span>{{ item.type }}</span>
<span>{{ item.wear }}</span>
</div>
<div class="skin-details" style="margin-top: 5px; color: var(--success-color);">
<span>💰 {{ "%.0f"|format(item.price_rub or 100) }} ₽</span>
</div>
<div class="item-actions">
<button onclick="sellSingleItem({{ item.id }})" class="btn-sell-single">
Продать
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<div class="empty-state-title">Ваш инвентарь пуст</div>
<div class="empty-state-desc">Откройте кейсы, чтобы получить первые предметы!</div>
<div class="empty-state-large">
<div class="empty-icon">📭</div>
<h3>Ваш инвентарь пуст</h3>
<p>Откройте кейсы, чтобы получить первые предметы!</p>
<a href="/cases" class="btn btn-primary">Открыть кейсы</a>
</div>
{% endif %}
</div>
</main>
<!-- Bulk Sell Modal -->
<div class="modal-overlay" id="bulkSellModal" style="display:none;">
<div class="modal" style="max-width:420px;">
<div class="modal-header">
<div class="modal-title">📦 Массовая продажа</div>
<button class="modal-close" onclick="closeBulkSellModal()"></button>
<!-- Модальное окно массовой продажи -->
<div id="bulkSellModal" class="bulk-sell-modal" style="display: none;">
<div class="bulk-sell-content">
<h3>📦 Массовая продажа</h3>
<p>Выберите редкости предметов для продажи:</p>
<div class="bulk-sell-options" id="bulkSellOptions">
<!-- Опции будут добавлены через JS -->
</div>
<div class="modal-body">
<p class="text-dim text-sm" style="margin-bottom:0.75rem;">Выберите редкости предметов для продажи:</p>
<div id="bulkSellOptions" style="display:flex;flex-direction:column;gap:0.35rem;margin-bottom:1rem;"></div>
<div class="text-sm" style="margin-bottom:0.5rem;">
<span>Будет продано: <strong id="bulkSellCount">0</strong></span>
<span style="margin-left:1rem;">Сумма: <strong id="bulkSellTotal" style="color:var(--success);">0 ₽</strong></span>
</div>
<div style="margin: 1rem 0;">
<p>Будет продано: <strong id="bulkSellCount">0</strong> предметов</p>
<p>Сумма: <strong id="bulkSellTotal" style="color: var(--success-color);">0 ₽</strong></p>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeBulkSellModal()">Отмена</button>
<button class="btn btn-danger" onclick="executeBulkSell()">Продать</button>
<div class="bulk-sell-actions">
<button class="btn btn-outline" onclick="closeBulkSellModal()">Отмена</button>
<button class="btn btn-primary" onclick="executeBulkSell()" style="background: var(--danger-color);">
Продать
</button>
</div>
</div>
</div>
@@ -131,62 +349,71 @@
<script>
let selectedItems = new Set();
let itemPrices = new Map();
document.querySelectorAll('.card[data-inventory-id]').forEach(card => {
// Инициализация цен
document.querySelectorAll('.inventory-skin-card').forEach(card => {
const id = parseInt(card.dataset.inventoryId);
const price = parseFloat(card.dataset.price);
itemPrices.set(id, price);
});
function onItemSelect(checkbox) {
const card = checkbox.closest('.card');
const card = checkbox.closest('.inventory-skin-card');
const itemId = parseInt(checkbox.dataset.id);
if (checkbox.checked) {
selectedItems.add(itemId);
card.style.borderColor = 'var(--danger)';
card.style.boxShadow = '0 0 15px rgba(239,68,68,0.25)';
card.classList.add('selected-for-sale');
} else {
selectedItems.delete(itemId);
card.style.borderColor = '';
card.style.boxShadow = '';
card.classList.remove('selected-for-sale');
document.getElementById('selectAllCheckbox').checked = false;
}
updateSelectionUI();
}
function toggleSelectAll() {
const selectAll = document.getElementById('selectAllCheckbox');
document.querySelectorAll('.item-select-checkbox').forEach(cb => {
const card = cb.closest('.card');
if (card && card.style.display !== 'none') {
const checkboxes = document.querySelectorAll('.item-select-checkbox');
checkboxes.forEach(cb => {
if (cb.closest('.inventory-skin-card').style.display !== 'none') {
cb.checked = selectAll.checked;
const card = cb.closest('.inventory-skin-card');
const itemId = parseInt(cb.dataset.id);
if (selectAll.checked) {
selectedItems.add(itemId);
card.style.borderColor = 'var(--danger)';
card.style.boxShadow = '0 0 15px rgba(239,68,68,0.25)';
card.classList.add('selected-for-sale');
} else {
selectedItems.delete(itemId);
card.style.borderColor = '';
card.style.boxShadow = '';
card.classList.remove('selected-for-sale');
}
}
});
updateSelectionUI();
}
function updateSelectionUI() {
const count = selectedItems.size;
const countBadge = document.getElementById('selectedCountBadge');
const priceBadge = document.getElementById('totalPriceBadge');
const sellBtn = document.getElementById('sellSelectedBtn');
if (count > 0) {
countBadge.style.display = 'inline-block';
countBadge.textContent = count;
countBadge.textContent = `${count} выбрано`;
let total = 0;
selectedItems.forEach(id => { total += itemPrices.get(id) || 100; });
selectedItems.forEach(id => {
total += itemPrices.get(id) || 100;
});
priceBadge.style.display = 'inline-block';
priceBadge.textContent = `${total.toFixed(0)} ₽`;
sellBtn.disabled = false;
} else {
countBadge.style.display = 'none';
@@ -197,8 +424,8 @@
async function refreshBalance() {
try {
const res = await fetch('/web/api/user/balance');
const data = await res.json();
const response = await fetch('/web/api/user/balance');
const data = await response.json();
if (data.success) {
document.querySelectorAll('.user-balance').forEach(el => {
el.textContent = `${Math.floor(data.balance)} ₽`;
@@ -206,164 +433,283 @@
}
} catch (e) {}
}
async function sellSingleItem(inventoryId) {
if (!confirm('Продать этот предмет?')) return;
SoundManager.click();
const formData = new FormData();
formData.append('inventory_id', inventoryId);
try {
const response = await fetch('/web/api/inventory/sell', { method: 'POST', body: formData });
const response = await fetch('/web/api/inventory/sell', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
const card = document.querySelector(`.card[data-inventory-id="${inventoryId}"]`);
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; card.style.transition = 'all 0.3s'; setTimeout(() => card.remove(), 300); }
SoundManager.balanceUpdate();
const card = document.querySelector(`.inventory-skin-card[data-id="${inventoryId}"]`);
if (card) {
card.style.transition = 'all 0.3s';
card.style.opacity = '0';
card.style.transform = 'scale(0.8)';
setTimeout(() => card.remove(), 300);
}
await refreshBalance();
} else { alert(data.error || 'Ошибка'); }
} catch (e) { alert('Ошибка соединения'); }
} else {
SoundManager.error();
alert(data.error || 'Ошибка при продаже');
}
} catch (error) {
SoundManager.error();
alert('Ошибка соединения');
}
}
async function sellSelected() {
if (selectedItems.size === 0) return;
let total = 0;
selectedItems.forEach(id => { total += itemPrices.get(id) || 100; });
selectedItems.forEach(id => {
total += itemPrices.get(id) || 100;
});
if (!confirm(`Продать ${selectedItems.size} предметов за ${total.toFixed(0)} ₽?`)) return;
SoundManager.click();
const ids = Array.from(selectedItems);
const formData = new FormData();
formData.append('inventory_ids', JSON.stringify(ids));
try {
const response = await fetch('/web/api/inventory/sell/bulk', { method: 'POST', body: formData });
const response = await fetch('/web/api/inventory/sell/bulk', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
ids.forEach(id => {
const card = document.querySelector(`.card[data-inventory-id="${id}"]`);
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; card.style.transition = 'all 0.3s'; setTimeout(() => card.remove(), 300); }
selectedItems.forEach(id => {
const card = document.querySelector(`.inventory-skin-card[data-id="${id}"]`);
if (card) {
card.style.transition = 'all 0.3s';
card.style.opacity = '0';
card.style.transform = 'scale(0.8)';
setTimeout(() => card.remove(), 300);
}
});
selectedItems.clear();
await refreshBalance();
updateSelectionUI();
} else { alert(data.error || 'Ошибка'); }
} catch (e) { alert('Ошибка соединения'); }
} else {
alert(data.error || 'Ошибка при продаже');
}
} catch (error) {
alert('Ошибка соединения');
}
}
function openBulkSellModal() {
const modal = document.getElementById('bulkSellModal');
const optionsDiv = document.getElementById('bulkSellOptions');
// Собираем уникальные редкости из видимых предметов
const rarities = new Set();
document.querySelectorAll('.card[data-inventory-id]').forEach(card => {
if (card.style.display !== 'none') rarities.add(card.dataset.rarity);
document.querySelectorAll('.inventory-skin-card').forEach(card => {
if (card.style.display !== 'none') {
rarities.add(card.dataset.rarity);
}
});
let html = `<label class="bulk-sell-option" style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.6rem;background:var(--bg-dark);border-radius:var(--radius-sm);cursor:pointer;">
<input type="checkbox" value="all" onchange="toggleAllRarities(this)" style="width:16px;height:16px;accent-color:var(--primary);">
<span style="flex:1;font-weight:500;" class="text-sm">Все предметы</span>
<span class="text-xs text-dim" id="totalVisibleCount">0</span>
</label>`;
// Создаем опции
optionsDiv.innerHTML = '';
Array.from(rarities).sort().forEach(rarity => {
const count = document.querySelectorAll(`.card[data-rarity="${rarity}"]`).length;
html += `<label style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.6rem;background:var(--bg-dark);border-radius:var(--radius-sm);cursor:pointer;">
<input type="checkbox" value="${rarity}" onchange="updateBulkSellCount()" style="width:16px;height:16px;accent-color:var(--primary);">
<span style="flex:1;" class="text-sm">${rarity}</span>
<span class="text-xs text-dim">${count} шт.</span>
</label>`;
const count = document.querySelectorAll(`.inventory-skin-card[data-rarity="${rarity}"]`).length;
const option = document.createElement('label');
option.className = 'bulk-sell-option';
option.innerHTML = `
<input type="checkbox" value="${rarity}" onchange="updateBulkSellCount()">
<span style="flex: 1;">
<span class="rarity-${rarity.toLowerCase().replace(/ /g, '-')}">${rarity}</span>
</span>
<span style="color: var(--text-secondary);">${count} шт.</span>
`;
optionsDiv.appendChild(option);
});
optionsDiv.innerHTML = html;
// Добавляем опцию "Все"
const allOption = document.createElement('label');
allOption.className = 'bulk-sell-option';
allOption.innerHTML = `
<input type="checkbox" value="all" onchange="toggleAllRarities(this)">
<span style="flex: 1; font-weight: 500;">Все предметы</span>
<span style="color: var(--text-secondary);" id="totalVisibleCount">0</span>
`;
optionsDiv.insertBefore(allOption, optionsDiv.firstChild);
updateVisibleCount();
updateBulkSellCount();
modal.style.display = 'flex';
}
function closeBulkSellModal() { document.getElementById('bulkSellModal').style.display = 'none'; }
function closeBulkSellModal() {
document.getElementById('bulkSellModal').style.display = 'none';
}
function toggleAllRarities(checkbox) {
document.querySelectorAll('#bulkSellOptions input[type="checkbox"]').forEach(cb => { if (cb !== checkbox) cb.checked = checkbox.checked; });
const checkboxes = document.querySelectorAll('#bulkSellOptions input[type="checkbox"]');
checkboxes.forEach(cb => {
if (cb !== checkbox) {
cb.checked = checkbox.checked;
}
});
updateBulkSellCount();
}
function updateVisibleCount() {
const visibleCards = document.querySelectorAll('.card[data-inventory-id]:not([style*="display: none"])');
const visibleCards = document.querySelectorAll('.inventory-skin-card:not([style*="display: none"])');
document.getElementById('totalVisibleCount').textContent = visibleCards.length;
}
function updateBulkSellCount() {
const selectedRarities = [];
document.querySelectorAll('#bulkSellOptions input[type="checkbox"]:checked').forEach(cb => {
if (cb.value && cb.value !== 'all') selectedRarities.push(cb.value);
if (cb.value && cb.value !== 'all') {
selectedRarities.push(cb.value);
}
});
const allChecked = document.querySelector('#bulkSellOptions input[value="all"]')?.checked;
let count = 0, total = 0;
document.querySelectorAll('.card[data-inventory-id]').forEach(card => {
let count = 0;
let total = 0;
document.querySelectorAll('.inventory-skin-card').forEach(card => {
if (card.style.display !== 'none') {
if (allChecked || selectedRarities.includes(card.dataset.rarity)) {
const rarity = card.dataset.rarity;
if (allChecked || selectedRarities.includes(rarity)) {
count++;
total += parseFloat(card.dataset.price) || 100;
}
}
});
document.getElementById('bulkSellCount').textContent = count;
document.getElementById('bulkSellTotal').textContent = `${total.toFixed(0)} ₽`;
}
async function executeBulkSell() {
const selectedRarities = [];
document.querySelectorAll('#bulkSellOptions input[type="checkbox"]:checked').forEach(cb => {
if (cb.value && cb.value !== 'all') selectedRarities.push(cb.value);
if (cb.value && cb.value !== 'all') {
selectedRarities.push(cb.value);
}
});
const allChecked = document.querySelector('#bulkSellOptions input[value="all"]')?.checked;
const idsToSell = [];
document.querySelectorAll('.card[data-inventory-id]').forEach(card => {
document.querySelectorAll('.inventory-skin-card').forEach(card => {
if (card.style.display !== 'none') {
if (allChecked || selectedRarities.includes(card.dataset.rarity)) {
const rarity = card.dataset.rarity;
if (allChecked || selectedRarities.includes(rarity)) {
idsToSell.push(parseInt(card.dataset.inventoryId));
}
}
});
if (idsToSell.length === 0) { alert('Выберите хотя бы одну редкость'); return; }
if (idsToSell.length === 0) {
alert('Выберите хотя бы одну редкость');
return;
}
if (!confirm(`Продать ${idsToSell.length} предметов?`)) return;
const formData = new FormData();
formData.append('inventory_ids', JSON.stringify(idsToSell));
try {
const response = await fetch('/web/api/inventory/sell/bulk', { method: 'POST', body: formData });
const response = await fetch('/web/api/inventory/sell/bulk', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
await refreshBalance();
document.querySelectorAll('.card[data-inventory-id]').forEach(card => {
card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; card.style.transition = 'all 0.3s'; setTimeout(() => card.remove(), 300);
// Remove all cards with animation
document.querySelectorAll('.inventory-skin-card').forEach(card => {
card.style.transition = 'all 0.3s';
card.style.opacity = '0';
card.style.transform = 'scale(0.8)';
setTimeout(() => card.remove(), 300);
});
} else { alert(data.error || 'Ошибка'); }
} catch (e) { alert('Ошибка соединения'); }
} else {
alert(data.error || 'Ошибка при продаже');
}
} catch (error) {
alert('Ошибка соединения');
}
}
function filterItems() {
const rarityFilter = document.getElementById('rarityFilter').value.toLowerCase();
const typeFilter = document.getElementById('typeFilter').value.toLowerCase();
const searchQuery = document.getElementById('searchInput').value.toLowerCase();
const items = document.querySelectorAll('.inventory-skin-card');
let visibleCount = 0;
document.querySelectorAll('.card[data-inventory-id]').forEach(item => {
const r = item.dataset.rarity.toLowerCase();
const t = item.dataset.type.toLowerCase();
const n = item.dataset.name;
items.forEach(item => {
const rarity = item.dataset.rarity.toLowerCase();
const type = item.dataset.type.toLowerCase();
const name = item.dataset.name;
let visible = true;
if (rarityFilter && r !== rarityFilter) visible = false;
if (typeFilter && t !== typeFilter) visible = false;
if (searchQuery && !n.includes(searchQuery)) visible = false;
item.style.display = visible ? '' : 'none';
if (rarityFilter && rarity !== rarityFilter) visible = false;
if (typeFilter && type !== typeFilter) visible = false;
if (searchQuery && !name.includes(searchQuery)) visible = false;
item.style.display = visible ? 'block' : 'none';
if (visible) visibleCount++;
});
document.getElementById('totalItemsCount').textContent = visibleCount;
// Сбрасываем выбор при фильтрации
selectedItems.clear();
document.querySelectorAll('.item-select-checkbox').forEach(cb => cb.checked = false);
document.querySelectorAll('.card[data-inventory-id]').forEach(c => { c.style.borderColor = ''; c.style.boxShadow = ''; });
document.querySelectorAll('.inventory-skin-card').forEach(c => c.classList.remove('selected-for-sale'));
document.getElementById('selectAllCheckbox').checked = false;
updateSelectionUI();
}
function resetFilters() {
document.getElementById('rarityFilter').value = '';
document.getElementById('typeFilter').value = '';
document.getElementById('searchInput').value = '';
filterItems();
}
document.getElementById('bulkSellModal').addEventListener('click', (e) => { if (e.target === e.currentTarget) closeBulkSellModal(); });
async function logout() {
await fetch('/web/api/auth/logout', { method: 'POST' });
window.location.href = '/';
}
// Закрытие модального окна по клику вне
document.getElementById('bulkSellModal').addEventListener('click', (e) => {
if (e.target.classList.contains('bulk-sell-modal')) {
closeBulkSellModal();
}
});
</script>
<script src="/static/js/sounds.js"></script>
<script src="/static/js/websocket.js"></script>
@@ -377,49 +723,80 @@
const btn = document.getElementById('soundToggle');
if (btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
});
</script>
<script>
// Живое обновление через WebSocket
WS.on('inventory_update', () => refreshInventory());
WS.on('item_sold', () => refreshInventory());
WS.on('balance_update', () => { if (typeof refreshBalance === 'function') refreshBalance(); });
WS.on('inventory_update', () => refreshInventory());
WS.on('item_sold', () => refreshInventory());
WS.on('balance_update', () => { if (typeof refreshBalance === 'function') refreshBalance(); });
let refreshTimeout;
function refreshInventory() {
clearTimeout(refreshTimeout);
refreshTimeout = setTimeout(() => {
fetch('/web/api/inventory/items')
.then(r => r.json())
.then(items => rebuildInventoryGrid(items))
.catch(() => {});
}, 800);
}
let refreshTimeout;
function refreshInventory() {
clearTimeout(refreshTimeout);
refreshTimeout = setTimeout(() => {
fetch('/web/api/inventory/items').then(r => r.json()).then(items => rebuildInventoryGrid(items)).catch(() => {});
}, 800);
}
function rebuildInventoryGrid(items) {
const grid = document.getElementById('inventoryGrid');
if (!grid) return;
if (!items.length) {
grid.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📭</div><div class="empty-state-title">Ваш инвентарь пуст</div><div class="empty-state-desc">Откройте кейсы, чтобы получить первые предметы!</div><a href="/cases" class="btn btn-primary">Открыть кейсы</a></div>';
document.getElementById('selectAllCheckbox') && (document.getElementById('selectAllCheckbox').checked = false);
selectedItems.clear(); updateSelectionUI(); return;
}
let html = '';
items.forEach(item => {
html += `<div class="card" style="position:relative;padding:0.85rem;" data-inventory-id="${item.id}" data-rarity="${item.rarity || ''}" data-type="${item.type || ''}" data-name="${(item.name||'').toLowerCase()}" data-price="${item.price_rub || 100}">
<input type="checkbox" class="item-select-checkbox" onchange="onItemSelect(this)" data-id="${item.id}" data-price="${item.price_rub || 100}" style="position:absolute;top:8px;left:8px;width:18px;height:18px;accent-color:var(--primary);z-index:2;cursor:pointer;">
<div style="width:100%;height:90px;display:flex;align-items:center;justify-content:center;background:var(--bg-dark);border-radius:var(--radius-sm);margin-bottom:0.5rem;">
<img src="${item.image_url || '/static/placeholder.png'}" alt="${item.name}" style="max-width:85%;max-height:80px;object-fit:contain;" onerror="this.src='/static/placeholder.png'">
</div>
<div class="text-sm truncate" title="${item.name}">${item.name}</div>
<div class="text-xs text-dim" style="margin-top:0.15rem;"><span>${item.rarity || ''}</span><span> Float: ${item.float ? item.float.toFixed(6) : ''}</span></div>
<div class="text-xs text-dim">${item.type || ''} / ${item.wear || ''}</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:0.5rem;">
<span style="color:var(--primary);font-weight:600;font-size:0.85rem;">💰 ${(item.price_rub || 100).toLocaleString()} ₽</span>
<button onclick="sellSingleItem(${item.id})" class="btn btn-ghost btn-sm" style="color:var(--danger);">Продать</button>
</div>
</div>`;
});
grid.innerHTML = html;
selectedItems.clear(); updateSelectionUI();
function rebuildInventoryGrid(items) {
const grid = document.getElementById('inventoryGrid');
if (!grid) return;
if (!items.length) {
grid.innerHTML = '<div class="empty-state-large"><div class="empty-icon">📭</div><h3>Ваш инвентарь пуст</h3><p>Откройте кейсы, чтобы получить первые предметы!</p><a href="/cases" class="btn btn-primary">Открыть кейсы</a></div>';
document.getElementById('selectAllCheckbox') && (document.getElementById('selectAllCheckbox').checked = false);
if (typeof filterItems === 'function') filterItems();
selectedItems.clear();
updateSelectionUI();
return;
}
let html = '';
items.forEach(item => {
const rarityClass = (item.rarity || 'unknown').toLowerCase().replace(/\s+/g, '-');
const image = item.image_url || '/static/placeholder.png';
const floatStr = item.float !== null && item.float !== undefined ? 'Float: ' + item.float.toFixed(6) : '';
const wearStr = item.wear || '';
const typeStr = item.type || '';
html += `<div class="inventory-skin-card rarity-${rarityClass}"
data-inventory-id="${item.id}"
data-rarity="${item.rarity || ''}"
data-type="${typeStr}"
data-name="${(item.name || '').toLowerCase()}"
data-price="${item.price_rub || 100}">
<input type="checkbox" class="item-select-checkbox"
onchange="onItemSelect(this)"
data-id="${item.id}"
data-price="${item.price_rub || 100}">
<div class="skin-image-container">
<img src="${image}" alt="${item.name}" class="skin-image" onerror="this.src='/static/placeholder.png'">
</div>
<div class="skin-info">
<div class="skin-name" title="${item.name}">${item.name}</div>
<div class="skin-details">
<span class="rarity-${rarityClass}">${item.rarity || ''}</span>
<span>${floatStr}</span>
</div>
<div class="skin-details" style="margin-top:5px">
<span>${typeStr}</span>
<span>${wearStr}</span>
</div>
<div class="skin-details" style="margin-top:5px;color:var(--success-color)">
<span>💰 ${(item.price_rub || 100).toLocaleString()} ₽</span>
</div>
<div class="item-actions">
<button onclick="sellSingleItem(${item.id})" class="btn-sell-single">Продать</button>
</div>
</div>
</div>`;
});
grid.innerHTML = html;
selectedItems.clear();
updateSelectionUI();
document.getElementById('selectAllCheckbox') && (document.getElementById('selectAllCheckbox').checked = false);
if (typeof filterItems === 'function') filterItems();
}
</script>
{% include '_activity_sidebar.html' %}
</body>
</html>
</html>
+30 -22
View File
@@ -3,41 +3,42 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход CS2 Simulator</title>
<title>Вход - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<nav class="navbar">
<div class="container">
<a href="/" class="logo">CS2 <span>Simulator</span></a>
<button class="mobile-toggle" id="mobileToggle"></button>
<div class="nav-links" id="navLinks">
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/" class="logo">🎮 CS2 Simulator</a>
<div class="nav-links">
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/register" class="btn btn-outline">Регистрация</a>
</div>
</div>
</nav>
<main style="display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 60px);padding:2rem 1rem;">
<div class="card" style="max-width:400px;width:100%;padding:2rem;">
<h1 class="display-font" style="font-size:1.3rem;margin-bottom:0.25rem;text-align:center;">Вход</h1>
<p class="text-dim text-sm text-center" style="margin-bottom:1.5rem;">Войдите в свой аккаунт</p>
<form id="loginForm">
<main class="auth-container">
<div class="auth-card">
<h2>Вход в аккаунт</h2>
<form id="loginForm" class="auth-form">
<div class="form-group">
<label class="form-label">Имя пользователя</label>
<input type="text" id="username" name="username" class="form-input" required minlength="3">
<label for="username">Имя пользователя</label>
<input type="text" id="username" name="username" required minlength="3">
</div>
<div class="form-group">
<label class="form-label">Пароль</label>
<input type="password" id="password" name="password" class="form-input" required minlength="4">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" required minlength="4">
</div>
<div id="errorMessage" class="form-error" style="display:none;margin-bottom:0.75rem;"></div>
<button type="submit" class="btn btn-primary btn-block btn-large">Войти</button>
<div id="errorMessage" class="error-message" style="display: none;"></div>
<button type="submit" class="btn btn-primary btn-block">Войти</button>
</form>
<p class="text-dim text-sm text-center" style="margin-top:1rem;">
Нет аккаунта? <a href="/register" style="color:var(--primary);">Зарегистрируйтесь</a>
<p class="auth-footer">
Нет аккаунта? <a href="/register">Зарегистрируйтесь</a>
</p>
</div>
</main>
@@ -45,11 +46,18 @@
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const errorDiv = document.getElementById('errorMessage');
try {
const response = await fetch('/web/api/auth/login', { method: 'POST', body: formData });
const response = await fetch('/web/api/auth/login', {
method: 'POST',
body: formData
});
const data = await response.json();
if (response.ok && data.success) {
window.location.href = data.redirect;
} else {
@@ -64,4 +72,4 @@
</script>
<script src="/static/js/safemode.js"></script>
</body>
</html>
</html>
+74 -56
View File
@@ -3,33 +3,33 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if is_public %}Профиль {{ profile_user.username }}{% else %}Профиль{% endif %} CS2 Simulator</title>
<title>{% if is_public %}Профиль {{ profile_user.username }}{% else %}Профиль{% endif %} - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<nav class="navbar">
<div class="container">
<a href="/" class="logo">CS2 <span>Simulator</span></a>
<button class="mobile-toggle" id="mobileToggle"></button>
<div class="nav-links" id="navLinks">
<a href="/" class="logo">🎮 CS2 Simulator</a>
<div class="nav-links">
{% if user %}
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</a>
<a href="/crash" class="nav-link">Crash</a>
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
<a href="/crash" class="nav-link">💥 Crash</a>
<a href="/inventory" class="nav-link">Инвентарь</a>
<a href="/activity" class="nav-link">Лента</a>
<a href="/achievements" class="nav-link">Достижения</a>
<a href="/profile" class="nav-link active">Профиль</a>
<a href="/activity" class="nav-link">📰 Лента</a>
<a href="/achievements" class="nav-link">🏆 Достижения</a>
<a href="/profile" class="nav-link">Профиль</a>
<span class="user-balance">{{ "%.2f"|format(user.balance) }} ₽</span>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-ghost">Выйти</button>
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-outline">Выйти</button>
{% else %}
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</a>
<a href="/activity" class="nav-link">Лента</a>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
<a href="/activity" class="nav-link">📰 Лента</a>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/login" class="btn btn-outline">Войти</a>
<a href="/register" class="btn btn-primary">Регистрация</a>
{% endif %}
@@ -37,23 +37,25 @@
</div>
</nav>
<main class="section">
<main class="profile-container">
<div class="container">
<div class="section-header">
<div>
<h1 class="section-title">{% if is_public %}👤 {{ profile_user.username }}{% else %}👤 {{ user.username }}{% endif %}</h1>
{% if is_public %}
<p class="section-subtitle">Зарегистрирован {{ profile_user.created_at.strftime('%d.%m.%Y') }}</p>
{% else %}
<p class="section-subtitle">Добро пожаловать в ваш профиль</p>
{% endif %}
<div class="profile-header">
{% if is_public %}
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem;">
<span style="background:var(--bg-card);padding:0.25rem 0.75rem;border-radius:20px;font-size:0.75rem;color:var(--text-secondary);border:1px solid var(--border-color);">👤 Публичный профиль</span>
</div>
<h1>👤 {{ profile_user.username }}</h1>
<p class="profile-subtitle">Зарегистрирован {{ profile_user.created_at.strftime('%d.%m.%Y') }}</p>
{% else %}
<h1>👤 {{ user.username }}</h1>
<p class="profile-subtitle">Добро пожаловать в ваш профиль</p>
{% endif %}
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ total_items }}</div>
<div class="stat-label">Предметов</div>
<div class="stat-label">Предметов в инвентаре</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ cases_opened }}</div>
@@ -61,7 +63,7 @@
</div>
<div class="stat-card">
<div class="stat-value">{{ contracts_completed }}</div>
<div class="stat-label">Контрактов</div>
<div class="stat-label">Выполнено контрактов</div>
</div>
{% if not is_public %}
<div class="stat-card">
@@ -70,56 +72,56 @@
</div>
{% endif %}
</div>
<div style="margin-top:2rem;display:grid;gap:1.5rem;">
<div class="profile-sections">
<div class="section">
<h2 class="display-font" style="font-size:1rem;margin-bottom:0.75rem;">🏆 Ценные предметы</h2>
<div class="grid grid-auto-sm">
<h2>🏆 Ценные предметы</h2>
<div class="items-grid">
{% set target_items = valuable_items %}
{% for item in target_items %}
<div class="card" style="text-align:center;padding:0.75rem;">
<div class="display-font text-sm">{{ item.market_hash_name }}</div>
<div class="text-xs text-dim">
<span>{{ item.rarity }}</span>
<span>Float: {{ "%.4f"|format(item.float_value) }}</span>
<div class="item-card rarity-{{ item.rarity|lower|replace(' ', '-') }}">
<div class="item-name">{{ item.market_hash_name }}</div>
<div class="item-details">
<span class="item-rarity">{{ item.rarity }}</span>
<span class="item-float">Float: {{ "%.4f"|format(item.float_value) }}</span>
</div>
</div>
{% else %}
<p class="text-dim text-sm">У {% if is_public %}этого пользователя{% else %}вас{% endif %} пока нет предметов.</p>
<p class="empty-state">У {% if is_public %}этого пользователя{% else %}вас{% endif %} пока нет предметов.</p>
{% endfor %}
</div>
</div>
<div class="section">
<h2 class="display-font" style="font-size:1rem;margin-bottom:0.75rem;">📦 Последние открытия</h2>
<div style="display:flex;flex-direction:column;gap:0.35rem;">
<h2>📦 Последние открытия</h2>
<div class="history-list">
{% for opening in recent_openings %}
<div class="card" style="padding:0.65rem 1rem;display:flex;align-items:center;gap:0.75rem;">
<span style="font-size:1.2rem;">📦</span>
<div style="flex:1;min-width:0;">
<div class="text-sm truncate">{{ opening.item_name }}</div>
<div class="text-xs text-dim">
<span>{{ opening.rarity }}</span>
<div class="history-item">
<div class="history-icon">📦</div>
<div class="history-content">
<div class="history-title">{{ opening.item_name }}</div>
<div class="history-meta">
<span class="rarity-{{ opening.rarity|lower|replace(' ', '-') }}">{{ opening.rarity }}</span>
<span>Float: {{ "%.4f"|format(opening.float_value) }}</span>
<span>{{ opening.opened_at.strftime('%d.%m.%Y %H:%M') }}</span>
</div>
</div>
</div>
{% else %}
<p class="text-dim text-sm">Пока нет открытий</p>
<p class="empty-state">Пока нет открытий</p>
{% endfor %}
</div>
</div>
<div class="section">
<h2 class="display-font" style="font-size:1rem;margin-bottom:0.75rem;">🔄 Последние контракты</h2>
<div style="display:flex;flex-direction:column;gap:0.35rem;">
<h2>🔄 Последние контракты</h2>
<div class="history-list">
{% for contract in recent_contracts %}
<div class="card" style="padding:0.65rem 1rem;display:flex;align-items:center;gap:0.75rem;">
<span style="font-size:1.2rem;">🔄</span>
<div style="flex:1;min-width:0;">
<div class="text-sm truncate">{{ contract.output_item_name }}</div>
<div class="text-xs text-dim">
<div class="history-item">
<div class="history-icon">🔄</div>
<div class="history-content">
<div class="history-title">{{ contract.output_item_name }}</div>
<div class="history-meta">
<span>Float: {{ "%.4f"|format(contract.output_float) }}</span>
<span>Шанс: {{ "%.2f"|format(contract.probability) }}%</span>
<span>{{ contract.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
@@ -127,7 +129,7 @@
</div>
</div>
{% else %}
<p class="text-dim text-sm">Пока нет контрактов</p>
<p class="empty-state">Пока нет контрактов</p>
{% endfor %}
</div>
</div>
@@ -135,7 +137,23 @@
</div>
</main>
<script>
async function logout() {
await fetch('/web/api/auth/logout', { method: 'POST' });
window.location.href = '/';
}
</script>
<script>
function toggleSound() {
const enabled = SoundManager.toggle();
document.getElementById('soundToggle').textContent = enabled ? '🔊' : '🔇';
}
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('soundToggle');
if (btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
});
</script>
<script src="/static/js/safemode.js"></script>
{% if user and not is_public %}{% include '_activity_sidebar.html' %}{% endif %}
</body>
</html>
</html>
+32 -24
View File
@@ -3,43 +3,44 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Регистрация CS2 Simulator</title>
<title>Регистрация - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<nav class="navbar">
<div class="container">
<a href="/" class="logo">CS2 <span>Simulator</span></a>
<button class="mobile-toggle" id="mobileToggle"></button>
<div class="nav-links" id="navLinks">
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/" class="logo">🎮 CS2 Simulator</a>
<div class="nav-links">
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/login" class="btn btn-outline">Войти</a>
</div>
</div>
</nav>
<main style="display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 60px);padding:2rem 1rem;">
<div class="card" style="max-width:400px;width:100%;padding:2rem;">
<h1 class="display-font" style="font-size:1.3rem;margin-bottom:0.25rem;text-align:center;">Регистрация</h1>
<p class="text-dim text-sm text-center" style="margin-bottom:1.5rem;">Создайте новый аккаунт</p>
<form id="registerForm">
<main class="auth-container">
<div class="auth-card">
<h2>Регистрация</h2>
<form id="registerForm" class="auth-form">
<div class="form-group">
<label class="form-label">Имя пользователя</label>
<input type="text" id="username" name="username" class="form-input" required minlength="3">
<div class="form-hint">Минимум 3 символа</div>
<label for="username">Имя пользователя</label>
<input type="text" id="username" name="username" required minlength="3">
<small>Минимум 3 символа</small>
</div>
<div class="form-group">
<label class="form-label">Пароль</label>
<input type="password" id="password" name="password" class="form-input" required minlength="4">
<div class="form-hint">Минимум 4 символа</div>
<label for="password">Пароль</label>
<input type="password" id="password" name="password" required minlength="4">
<small>Минимум 4 символа</small>
</div>
<div id="errorMessage" class="form-error" style="display:none;margin-bottom:0.75rem;"></div>
<button type="submit" class="btn btn-primary btn-block btn-large">Зарегистрироваться</button>
<div id="errorMessage" class="error-message" style="display: none;"></div>
<button type="submit" class="btn btn-primary btn-block">Зарегистрироваться</button>
</form>
<p class="text-dim text-sm text-center" style="margin-top:1rem;">
Уже есть аккаунт? <a href="/login" style="color:var(--primary);">Войдите</a>
<p class="auth-footer">
Уже есть аккаунт? <a href="/login">Войдите</a>
</p>
</div>
</main>
@@ -47,11 +48,18 @@
<script>
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const errorDiv = document.getElementById('errorMessage');
try {
const response = await fetch('/web/api/auth/register', { method: 'POST', body: formData });
const response = await fetch('/web/api/auth/register', {
method: 'POST',
body: formData
});
const data = await response.json();
if (response.ok && data.success) {
window.location.href = data.redirect;
} else {
@@ -66,4 +74,4 @@
</script>
<script src="/static/js/safemode.js"></script>
</body>
</html>
</html>
+640 -179
View File
@@ -6,30 +6,30 @@
<title>Апгрейд — CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<body class="upgrade-page">
<nav class="navbar">
<div class="container">
<a href="/" class="logo">CS2 <span>Simulator</span></a>
<button class="mobile-toggle" id="mobileToggle"></button>
<div class="nav-links" id="navLinks">
<a href="/" class="logo">🎮 CS2 Simulator</a>
<div class="nav-links">
{% if user %}
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link active">Апгрейд</a>
<a href="/crash" class="nav-link">Crash</a>
<a href="/upgrade" class="nav-link active">🆙 Апгрейд</a>
<a href="/crash" class="nav-link">💥 Crash</a>
<a href="/inventory" class="nav-link">Инвентарь</a>
<a href="/activity" class="nav-link">Лента</a>
<a href="/achievements" class="nav-link">Достижения</a>
<a href="/activity" class="nav-link">📰 Лента</a>
<a href="/achievements" class="nav-link">🏆 Достижения</a>
<a href="/profile" class="nav-link">Профиль</a>
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-ghost">Выйти</button>
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-outline">Выйти</button>
{% else %}
<a href="/cases" class="nav-link">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link active">Апгрейд</a>
<a href="/activity" class="nav-link">Лента</a>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/upgrade" class="nav-link active">🆙 Апгрейд</a>
<a href="/activity" class="nav-link">📰 Лента</a>
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
<a href="/login" class="btn btn-outline">Войти</a>
<a href="/register" class="btn btn-primary">Регистрация</a>
{% endif %}
@@ -37,303 +37,700 @@
</div>
</nav>
<main class="section">
<main class="upgrade-container">
<div class="container">
<div class="section-header">
<h1 class="section-title">🆙 Апгрейд</h1>
<p class="section-subtitle">Рискни предметом ради более дорогого</p>
<div class="upgrade-header">
<h1>🆙 Апгрейд</h1>
<p>Рискни предметом ради более дорогого</p>
</div>
<div style="display:grid;grid-template-columns:1fr 320px 1fr;gap:0.75rem;align-items:start;">
<div class="upgrade-layout">
<!-- Inventory -->
<div class="card" style="padding:0;overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:0.6rem 0.75rem;border-bottom:1px solid var(--border);background:rgba(0,0,0,0.1);">
<span class="display-font text-sm">📦 Инвентарь <span class="text-xs text-dim">{{ inventory_items|length }}</span></span>
<div class="flex gap-1">
<input type="text" id="invSearch" class="form-input" placeholder="Поиск..." style="width:auto;min-width:100px;font-size:0.75rem;padding:0.25rem 0.5rem;" oninput="onInvSearch()">
<button class="btn-icon btn-sm" onclick="setInvSort('default',this)">📋</button>
<button class="btn-icon btn-sm" onclick="setInvSort('price_asc',this)">💰↑</button>
<button class="btn-icon btn-sm" onclick="setInvSort('price_desc',this)">💰↓</button>
<div class="inventory-panel">
<div class="panel-header">
<div class="panel-title">
<span>📦 Инвентарь</span>
<span style="font-size: 0.65rem; color: #6b6b7a;">{{ inventory_items|length }}</span>
</div>
<div class="inv-filter-bar">
<input type="text" class="inv-search-input" id="invSearch" placeholder="Поиск..." oninput="onInvSearch()">
<div class="inv-sort-group">
<button class="inv-sort-btn" onclick="setInvSort('default', this)">📋</button>
<button class="inv-sort-btn" onclick="setInvSort('price_asc', this)">💰↑</button>
<button class="inv-sort-btn" onclick="setInvSort('price_desc', this)">💰↓</button>
</div>
</div>
</div>
<div style="max-height:400px;overflow-y:auto;padding:0.5rem;" id="inventoryList"></div>
<div class="inventory-list" id="inventoryList"></div>
</div>
<!-- Wheel -->
<div class="card" style="text-align:center;">
<div style="display:flex;align-items:center;justify-content:center;gap:0.75rem;margin-bottom:1rem;">
<div style="text-align:center;flex:1;" id="selectedInputDisplay">
<div style="width:60px;height:60px;margin:0 auto 0.25rem;background:var(--bg-dark);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;border:2px solid var(--border);">
<img src="/static/placeholder.png" id="displayInputImage" style="max-width:85%;max-height:50px;object-fit:contain;">
<div class="wheel-panel" style="position: relative;">
<div class="selected-items-display">
<div class="selected-item-mini" id="selectedInputDisplay">
<div class="item-frame">
<img src="/static/placeholder.png" id="displayInputImage">
</div>
<div class="text-xs truncate" id="displayInputName">Не выбрано</div>
<div class="text-xs text-primary" id="displayInputPrice"></div>
<div class="selected-item-mini-name" id="displayInputName">Не выбрано</div>
<div class="selected-item-mini-price" id="displayInputPrice"></div>
</div>
<div style="font-size:1.5rem;color:var(--text-dim);"></div>
<div style="text-align:center;flex:1;" id="selectedTargetDisplay">
<div style="width:60px;height:60px;margin:0 auto 0.25rem;background:var(--bg-dark);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;border:2px solid var(--border);">
<img src="/static/placeholder.png" id="displayTargetImage" style="max-width:85%;max-height:50px;object-fit:contain;">
<div class="arrow-icon"></div>
<div class="selected-item-mini" id="selectedTargetDisplay">
<div class="item-frame">
<img src="/static/placeholder.png" id="displayTargetImage">
</div>
<div class="text-xs truncate" id="displayTargetName">Не выбрано</div>
<div class="text-xs text-primary" id="displayTargetPrice"></div>
<div class="selected-item-mini-name" id="displayTargetName">Не выбрано</div>
<div class="selected-item-mini-price" id="displayTargetPrice"></div>
</div>
</div>
<div style="position:relative;width:180px;height:180px;margin:0 auto 1rem;">
<div style="position:absolute;inset:0;border-radius:50%;border:3px solid var(--border);" class="wheel" id="upgradeWheel" style="--probability:0;">
<div style="position:absolute;top:-10px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:10px solid transparent;border-right:10px solid transparent;border-top:16px solid var(--primary);z-index:2;" id="pointerContainer"></div>
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:80px;height:80px;border-radius:50%;background:var(--bg-dark);display:flex;align-items:center;justify-content:center;border:2px solid var(--border);z-index:1;">
<span class="display-font text-sm" id="wheelProbability">0%</span>
</div>
<div class="wheel-container">
<div class="wheel-outer-ring"></div>
<div class="wheel" id="upgradeWheel" style="--probability: 0;"></div>
<div class="wheel-ticks"></div>
<div class="wheel-pointer-container" id="pointerContainer">
<div class="wheel-pointer"></div>
</div>
<div class="wheel-center" id="wheelProbability">0%</div>
</div>
<div style="display:flex;gap:0.5rem;justify-content:center;">
<div class="upgrade-actions">
{% if user %}
<button class="btn btn-primary btn-large" onclick="executeUpgrade()" id="upgradeBtn" disabled>🎲 Апгрейд</button>
<button class="btn-icon" id="quickModeBtn" onclick="toggleQuickMode()" title="Быстрый режим"></button>
<button class="upgrade-btn" onclick="executeUpgrade()" id="upgradeBtn" disabled>
🎲 Апгрейд
</button>
<button class="quick-btn" id="quickModeBtn" onclick="toggleQuickMode()" title="Быстрый режим"></button>
{% else %}
<div class="flex gap-2" style="align-items:center;">
<span class="text-dim text-sm">🔒 Войдите, чтобы совершать апгрейды</span>
<a href="/login" class="btn btn-primary btn-sm">Войти</a>
<a href="/register" class="btn btn-outline btn-sm">Регистрация</a>
<div style="display:flex;align-items:center;gap:1rem;width:100%;justify-content:center;padding:1rem;">
<span style="color:var(--text-secondary);">🔒 Войдите, чтобы совершать апгрейды</span>
<a href="/login" class="btn btn-primary">Войти</a>
<a href="/register" class="btn btn-outline">Регистрация</a>
</div>
{% endif %}
</div>
<div id="toastContainer" class="toast-container"></div>
</div>
<!-- Target -->
<div class="card" style="padding:0;overflow:hidden;">
<div style="padding:0.6rem 0.75rem;border-bottom:1px solid var(--border);background:rgba(0,0,0,0.1);">
<span class="display-font text-sm">🎯 Цель</span>
<input type="text" id="targetSearch" class="form-input" placeholder="Поиск..." style="width:100%;margin-top:0.35rem;font-size:0.75rem;padding:0.25rem 0.5rem;" oninput="searchTargetItems()">
<div class="flex gap-1" style="margin-top:0.35rem;flex-wrap:wrap;">
<button class="filter-chip active" onclick="setTargetSort('closest',this)">🎯 Ближ.</button>
<button class="filter-chip" onclick="setTargetSort('price_desc',this)">💎 Дорогие</button>
<button class="filter-chip" onclick="setTargetSort('price_asc',this)">💰 Дешёвые</button>
<button class="filter-chip" onclick="setTargetSort('name',this)">📋 Имя</button>
<div class="target-panel">
<div class="target-header">
<div class="panel-title" style="margin-bottom: 0.4rem;">
<span>🎯 Цель</span>
</div>
<div class="flex gap-1" style="margin-top:0.35rem;flex-wrap:wrap;display:none;" id="priceFilterBar">
<span class="text-xs text-dim" style="padding:0.2rem 0;">Шанс</span>
<button class="filter-chip" onclick="setPriceFilter('probhigh',this)">75%</button>
<button class="filter-chip" onclick="setPriceFilter('probmid',this)">50%</button>
<button class="filter-chip" onclick="setPriceFilter('probalow',this)">25%</button>
<button class="filter-chip" onclick="setPriceFilter('proba10',this)">10%</button>
<span class="text-xs text-dim" style="padding:0.2rem 0;">×</span>
<button class="filter-chip" onclick="setPriceFilter('2',this)">2×</button>
<button class="filter-chip" onclick="setPriceFilter('4',this)">4×</button>
<button class="filter-chip" onclick="setPriceFilter('8',this)">8×</button>
<button class="filter-chip" onclick="setPriceFilter('10',this)">10×</button>
<input type="text" class="target-search-input" id="targetSearch" placeholder="Поиск..." oninput="searchTargetItems()">
<div class="sort-controls">
<button class="sort-btn" onclick="setTargetSort('closest', this)">🎯 Ближайшие</button>
<button class="sort-btn" onclick="setTargetSort('price_desc', this)">💎 Дорогие</button>
<button class="sort-btn" onclick="setTargetSort('price_asc', this)">💰 Дешёвые</button>
<button class="sort-btn" onclick="setTargetSort('name', this)">📋 Имя</button>
</div>
<div class="price-filter-bar" id="priceFilterBar" style="display: none;">
<span style="font-size:0.55rem;color:#4a4a5a;text-transform:uppercase;letter-spacing:0.04em;padding:0.2rem 0;margin-right:0.15rem;">Шанс</span>
<button class="pf-btn" onclick="setPriceFilter('probhigh', this)">75%</button>
<button class="pf-btn" onclick="setPriceFilter('probmid', this)">50%</button>
<button class="pf-btn" onclick="setPriceFilter('probalow', this)">25%</button>
<button class="pf-btn" onclick="setPriceFilter('proba10', this)">10%</button>
<span style="font-size:0.55rem;color:#4a4a5a;text-transform:uppercase;letter-spacing:0.04em;padding:0.2rem 0;margin:0 0.15rem;">×</span>
<button class="pf-btn" onclick="setPriceFilter('2', this)">2×</button>
<button class="pf-btn" onclick="setPriceFilter('4', this)">4×</button>
<button class="pf-btn" onclick="setPriceFilter('8', this)">8×</button>
<button class="pf-btn" onclick="setPriceFilter('10', this)">10×</button>
</div>
</div>
<div style="max-height:400px;overflow-y:auto;padding:0.5rem;" id="targetList">
<div class="text-dim text-sm text-center" style="padding:2rem;">Выберите предмет</div>
<div class="target-list" id="targetList">
<div class="empty-state">Выберите предмет</div>
</div>
</div>
</div>
<!-- History -->
<div class="section">
<h3 class="display-font" style="font-size:0.95rem;margin-bottom:0.75rem;">📜 История <span class="text-dim text-xs" id="historyCount">{{ recent_upgrades|length }}</span></h3>
<div class="grid grid-auto-sm" id="historyGrid"></div>
<div class="history-section">
<h3 class="history-title">📜 История <span class="history-count" id="historyCount">{{ recent_upgrades|length }}</span></h3>
<div class="history-grid" id="historyGrid"></div>
</div>
</div>
</main>
<script src="/static/js/sounds.js"></script>
<script src="/static/js/websocket.js"></script>
<script src="/static/js/safemode.js"></script>
<script>
let selectedInput = null, selectedTarget = null, inventoryItems = {{ inventory_items|tojson }}, allTargetItems = [];
let currentSearchQuery = '', currentSort = 'closest', currentPriceFilter = null, isSpinning = false, quickMode = false, invSearchQuery = '', invSort = 'default';
// === Фразы для побед/поражений ===
const PHRASE_PARTS = {
win: {
pre: ['🎉','🔥','⭐','🏆','💎','✨','🎯','👑','💫','🌟','🎰','💪','🥇','🎊','🏅'],
verb: ['Победа','Джекпот','Успех','Красава','В дамках','Есть контакт','Повезло','Забрал','Выбил','Сорвал куш','Всё пучком','Лут','Топчик','Козырь','Фартануло'],
noun: ['! 🔥','! 💎','! 🏆','! 👑','! ✨','! 💪','!','!','!'],
},
lose: {
pre: ['😔','💔','😢','💀','😭','😤','😩','💸','😞','😓','🤡','💀','😅','🙃','🤷'],
verb: ['Не повезло','Облом','Мимо','Пролёт','Не выпал','Не срослось','Пусто','Мимо кассы','В следующий раз','Не фортануло','Повезёт потом','Ноль','Минус','Увы','Печаль'],
noun: [' 😔',' 💔','...',' 😢',' 😭',' 💸','...'],
}
};
let usedPhraseKeys = new Set();
function getRandomPhrase(type) {
const parts = PHRASE_PARTS[type];
for (let attempt = 0; attempt < 100; attempt++) {
const pre = parts.pre[Math.floor(Math.random() * parts.pre.length)];
const verb = parts.verb[Math.floor(Math.random() * parts.verb.length)];
const noun = parts.noun[Math.floor(Math.random() * parts.noun.length)];
const key = pre + '|' + verb + '|' + noun;
if (!usedPhraseKeys.has(key)) {
usedPhraseKeys.add(key);
return pre + ' ' + verb + noun;
}
}
usedPhraseKeys.clear();
return getRandomPhrase(type);
}
document.addEventListener('DOMContentLoaded', () => { loadInitialTargets(); renderHistory({{ recent_upgrades|tojson }}); renderInventory(); });
let selectedInput = null;
let selectedTarget = null;
let inventoryItems = {{ inventory_items|tojson }};
let allTargetItems = [];
let currentSearchQuery = '';
let currentSort = 'closest';
let currentPriceFilter = null;
let isSpinning = false;
let quickMode = false;
let invSearchQuery = '';
let invSort = 'default';
document.addEventListener('DOMContentLoaded', () => {
loadInitialTargets();
renderHistory({{ recent_upgrades|tojson }});
renderInventory();
});
function renderInventory() {
let items = [...inventoryItems];
if (invSearchQuery.length >= 1) { const q = invSearchQuery.toLowerCase(); items = items.filter(i => i.name.toLowerCase().includes(q)); }
if (invSort === 'price_asc') items.sort((a,b) => a.price_rub - b.price_rub);
else if (invSort === 'price_desc') items.sort((a,b) => b.price_rub - a.price_rub);
if (invSearchQuery.length >= 1) {
const q = invSearchQuery.toLowerCase();
items = items.filter(i => i.name.toLowerCase().includes(q));
}
if (invSort === 'price_asc') {
items.sort((a, b) => a.price_rub - b.price_rub);
} else if (invSort === 'price_desc') {
items.sort((a, b) => b.price_rub - a.price_rub);
}
const container = document.getElementById('inventoryList');
if (!items.length) { container.innerHTML = '<div class="text-dim text-sm text-center" style="padding:1rem;">Ничего не найдено</div>'; return; }
container.innerHTML = items.map(item => `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.5rem;border-radius:var(--radius-sm);cursor:pointer;transition:all 0.15s;${selectedInput && selectedInput.inventory_id === item.id ? 'background:var(--primary-subtle);border:1px solid rgba(245,158,11,0.2);' : 'border:1px solid transparent;'}" onclick="selectInputItem(${item.id})" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='${selectedInput && selectedInput.inventory_id === item.id ? 'var(--primary-subtle)' : ''}'">
<img src="${item.image_url||'/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" style="width:40px;height:32px;object-fit:contain;flex-shrink:0;">
<div style="flex:1;min-width:0;"><div class="text-xs truncate" title="${item.name}">${item.name}</div><div class="text-xs text-dim">${item.rarity} <span class="text-primary">${Math.floor(item.price_rub).toLocaleString()} ₽</span></div></div>
</div>`).join('');
if (items.length === 0) {
container.innerHTML = '<div class="empty-state" style="padding:1rem;text-align:center;color:var(--text-secondary)">Ничего не найдено</div>';
return;
}
container.innerHTML = items.map(item => `
<div class="inventory-item-card ${selectedInput && selectedInput.inventory_id === item.id ? 'selected' : ''}"
onclick="selectInputItem(${item.id})" data-id="${item.id}" data-price="${item.price_rub}">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="inventory-item-info">
<div class="inventory-item-name" title="${item.name}">${item.name}</div>
<div class="inventory-item-meta">
<span>${item.rarity}</span>
<span class="inventory-item-price">${Math.floor(item.price_rub).toLocaleString()} ₽</span>
</div>
</div>
</div>
`).join('');
}
async function loadInitialTargets() {
try { const r = await fetch('/admin/api/items/search?q=&limit=600'); allTargetItems = await r.json(); allTargetItems = allTargetItems.filter((item,index,self) => index === self.findIndex(t => t.id === item.id)); applySortAndFilter(); } catch(e) {}
try {
const response = await fetch(`/admin/api/items/search?q=&limit=600`);
allTargetItems = await response.json();
allTargetItems = allTargetItems.filter((item, index, self) =>
index === self.findIndex(t => t.id === item.id)
);
currentSort = 'closest';
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
document.querySelector('.sort-btn[onclick*="closest"]')?.classList.add('active');
applySortAndFilter();
} catch (e) {}
}
async function loadTargetsForInput(inputPrice) {
try { const minP = Math.round(inputPrice*1.05), maxP = Math.round(inputPrice*19); const r = await fetch(`/admin/api/items/search?q=&min_price=${minP}&max_price=${maxP}&limit=600`); const items = await r.json(); allTargetItems = items.filter((item,index,self) => index === self.findIndex(t => t.id === item.id)); applySortAndFilter(); } catch(e) { applySortAndFilter(); }
try {
// Поиск только по цене — больше выбора и приколюх
const minP = Math.round(inputPrice * 1.05);
const maxP = Math.round(inputPrice * 19);
const resp = await fetch(`/admin/api/items/search?q=&min_price=${minP}&max_price=${maxP}&limit=600`);
const items = await resp.json();
allTargetItems = items.filter((item, index, self) =>
index === self.findIndex(t => t.id === item.id)
);
currentSort = 'closest';
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
document.querySelector('.sort-btn[onclick*="closest"]')?.classList.add('active');
applySortAndFilter();
} catch (e) {
console.error('Failed to load targets:', e);
applySortAndFilter();
}
}
function selectInputItem(itemId) {
if (isSpinning) return;
const item = inventoryItems.find(i => i.id === itemId); if (!item) return;
selectedInput = { inventory_id: item.id, item_id: item.item_id, name: item.name, price: item.price_rub, image_url: item.image_url, rarity: item.rarity };
const item = inventoryItems.find(i => i.id === itemId);
if (!item) return;
selectedInput = {
inventory_id: item.id,
item_id: item.item_id,
name: item.name,
price: item.price_rub,
image_url: item.image_url,
rarity: item.rarity
};
renderInventory();
document.getElementById('displayInputImage').src = item.image_url || '/static/placeholder.png';
document.getElementById('displayInputName').textContent = item.name.substring(0,25);
document.getElementById('displayInputName').textContent = item.name.substring(0, 25);
document.getElementById('displayInputPrice').textContent = `${Math.floor(item.price_rub).toLocaleString()} ₽`;
document.getElementById('selectedInputDisplay').classList.add('selected');
document.getElementById('priceFilterBar').style.display = 'flex';
currentPriceFilter = null; currentSort = 'closest';
document.querySelectorAll('.filter-chip').forEach(b => b.classList.remove('active'));
document.querySelector('.filter-chip[onclick*="closest"]')?.classList.add('active');
currentPriceFilter = null;
document.querySelectorAll('.pf-btn').forEach(b => b.classList.remove('active'));
currentSort = 'closest';
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
document.querySelector('.sort-btn[onclick*="closest"]')?.classList.add('active');
loadTargetsForInput(item.price_rub);
updateProbability();
}
function applySortAndFilter() {
if (!allTargetItems.length) return;
let filtered = [...allTargetItems];
if (currentSearchQuery.length >= 2) filtered = filtered.filter(item => item.name.toLowerCase().includes(currentSearchQuery.toLowerCase()));
if (currentSearchQuery.length >= 2) {
filtered = filtered.filter(item =>
item.name.toLowerCase().includes(currentSearchQuery.toLowerCase())
);
}
if (selectedInput) {
const inputPrice = selectedInput.price;
filtered = filtered.filter(item => { const p = item.price_rub||100; return p > inputPrice && p <= inputPrice*19; });
filtered = filtered.filter(item => {
const price = item.price_rub || 100;
if (price <= inputPrice) return false;
const maxPrice = inputPrice * 19;
return price <= maxPrice;
});
if (currentPriceFilter) {
filtered = filtered.filter(item => {
const p = item.price_rub||100, ratio = p/inputPrice;
switch(currentPriceFilter) {
case '2': return ratio>=1.8&&ratio<=2.5; case '4': return ratio>=3.5&&ratio<=5; case '8': return ratio>=7&&ratio<=10; case '10': return ratio>=8.5&&ratio<=12;
case 'probalow': {const prob=(inputPrice/p)*100; return prob>=20&&prob<=30;} case 'probmid': {const prob=(inputPrice/p)*100; return prob>=45&&prob<=55;} case 'probhigh': {const prob=(inputPrice/p)*100; return prob>=70&&prob<=75;} case 'proba10': {const prob=(inputPrice/p)*100; return prob>=8&&prob<=12;}
const price = item.price_rub || 100;
const ratio = price / inputPrice;
const prob = (inputPrice / price) * 100;
switch (currentPriceFilter) {
case '2': return ratio >= 1.8 && ratio <= 2.5;
case '4': return ratio >= 3.5 && ratio <= 5;
case '8': return ratio >= 7 && ratio <= 10;
case '10': return ratio >= 8.5 && ratio <= 12;
case 'probalow': return prob >= 20 && prob <= 30;
case 'probmid': return prob >= 45 && prob <= 55;
case 'probhigh': return prob >= 70 && prob <= 75;
case 'proba10': return prob >= 8 && prob <= 12;
default: return true;
}
});
}
}
if (currentSort === 'closest' && selectedInput) {
const ip = selectedInput.price;
filtered.sort((a,b) => { const da = Math.abs(Math.min(75,(ip/(a.price_rub||100))*100)-75), db = Math.abs(Math.min(75,(ip/(b.price_rub||100))*100)-75); return da!==db?da-db:(a.price_rub||0)-(b.price_rub||0); });
} else if (currentSort === 'price_asc') filtered.sort((a,b) => (a.price_rub||0)-(b.price_rub||0));
else if (currentSort === 'price_desc') filtered.sort((a,b) => (b.price_rub||0)-(a.price_rub||0));
else filtered.sort((a,b) => a.name.localeCompare(b.name));
displayTargetItems(filtered.slice(0,120));
const inputPrice = selectedInput.price;
filtered.sort((a, b) => {
const pa = (inputPrice / (a.price_rub || 100)) * 100;
const pb = (inputPrice / (b.price_rub || 100)) * 100;
const da = Math.abs(Math.min(75, pa) - 75);
const db = Math.abs(Math.min(75, pb) - 75);
if (da !== db) return da - db;
return (a.price_rub || 0) - (b.price_rub || 0);
});
} else if (currentSort === 'price_asc') {
filtered.sort((a, b) => (a.price_rub || 0) - (b.price_rub || 0));
} else if (currentSort === 'price_desc') {
filtered.sort((a, b) => (b.price_rub || 0) - (a.price_rub || 0));
} else {
filtered.sort((a, b) => a.name.localeCompare(b.name));
}
displayTargetItems(filtered.slice(0, 120));
}
function setTargetSort(sort, btn) { currentSort = sort; document.querySelectorAll('.filter-chip').forEach(b => b.classList.remove('active')); if (btn) btn.classList.add('active'); applySortAndFilter(); }
function setPriceFilter(mult, btn) { currentPriceFilter = mult === currentPriceFilter ? null : mult; document.querySelectorAll('.filter-chip').forEach(b => b.classList.remove('active')); if (currentPriceFilter && btn) btn.classList.add('active'); applySortAndFilter(); }
function onInvSearch() { invSearchQuery = document.getElementById('invSearch').value; renderInventory(); }
function setInvSort(sort, btn) { invSort = sort; document.querySelectorAll('.btn-icon').forEach(b => b.classList.remove('active')); if (btn) btn.classList.add('active'); renderInventory(); }
function toggleQuickMode() { quickMode = !quickMode; document.getElementById('quickModeBtn').classList.toggle('active', quickMode); }
function setTargetSort(sort, btn) {
currentSort = sort;
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
applySortAndFilter();
}
function setPriceFilter(mult, btn) {
currentPriceFilter = mult === currentPriceFilter ? null : mult;
document.querySelectorAll('.pf-btn').forEach(b => b.classList.remove('active'));
if (currentPriceFilter && btn) btn.classList.add('active');
applySortAndFilter();
}
function onInvSearch() {
invSearchQuery = document.getElementById('invSearch').value;
renderInventory();
}
function setInvSort(sort, btn) {
invSort = sort;
document.querySelectorAll('.inv-sort-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
renderInventory();
}
function toggleQuickMode() {
quickMode = !quickMode;
document.getElementById('quickModeBtn').classList.toggle('active', quickMode);
}
async function searchTargetItems() {
currentSearchQuery = document.getElementById('targetSearch').value;
const query = document.getElementById('targetSearch').value;
currentSearchQuery = query;
const container = document.getElementById('targetList');
if (currentSearchQuery.length < 2) { applySortAndFilter(); return; }
container.innerHTML = '<div class="text-dim text-sm text-center" style="padding:2rem;">🔍 Поиск…</div>';
try { const r = await fetch(`/admin/api/items/search?q=${encodeURIComponent(currentSearchQuery)}&limit=600`); const items = await r.json(); allTargetItems = items.filter((item,index,self) => index === self.findIndex(t => t.id === item.id)); applySortAndFilter(); } catch(e) { container.innerHTML = '<div class="text-dim text-sm text-center">Ошибка</div>'; }
if (query.length < 2) {
applySortAndFilter();
return;
}
container.innerHTML = '<div class="empty-state">🔍 Поиск…</div>';
try {
const response = await fetch(`/admin/api/items/search?q=${encodeURIComponent(query)}&limit=600`);
const items = await response.json();
allTargetItems = items.filter((item, index, self) =>
index === self.findIndex(t => t.id === item.id)
);
applySortAndFilter();
} catch (e) {
container.innerHTML = '<div class="empty-state">Ошибка</div>';
}
}
function displayTargetItems(items) {
const container = document.getElementById('targetList');
if (!items.length) { container.innerHTML = '<div class="text-dim text-sm text-center" style="padding:2rem;">Ничего не найдено</div>'; return; }
if (items.length === 0) {
container.innerHTML = '<div class="empty-state">Ничего не найдено</div>';
return;
}
container.innerHTML = items.map(item => {
const price = item.price_rub||100, imageUrl = item.image_url||'/static/placeholder.png';
let disabled = false, disabledReason = '';
if (selectedInput) { const minP = selectedInput.price*0.5, maxP = selectedInput.price*19; if (price < minP) { disabled = true; disabledReason = 'Слишком дешёвый'; } else if (price > maxP) { disabled = true; disabledReason = 'Слишком дорогой'; } }
const safeName = item.name.replace(/'/g,"\\'"); const safeUrl = imageUrl.replace(/'/g,"\\'");
return `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.5rem;border-radius:var(--radius-sm);cursor:pointer;transition:all 0.15s;${disabled ? 'opacity:0.35;cursor:not-allowed;' : ''}border:1px solid transparent;" class="target-item-card ${disabled ? 'disabled' : ''}" onclick="${disabled ? `alert('${disabledReason}')` : `selectTargetItem(${item.id},'${safeName}',${price},'${safeUrl}','${item.rarity||'Unknown'}')`}" onmouseover="${disabled ? '' : 'this.style.background=\'var(--bg-hover)\''}" onmouseout="this.style.background=''" title="${disabled ? disabledReason : ''}">
<img src="${imageUrl}" onerror="this.src='/static/placeholder.png'" style="width:36px;height:28px;object-fit:contain;flex-shrink:0;">
<div style="flex:1;min-width:0;"><div class="text-xs truncate" title="${item.name}">${item.name}</div><div class="text-xs text-dim">${item.rarity||''} <span class="text-primary">${price.toLocaleString()} ₽</span></div></div>
</div>`;
const price = item.price_rub || 100;
const imageUrl = item.image_url || '/static/placeholder.png';
let isDisabled = false;
let disabledReason = '';
if (selectedInput) {
const minPrice = selectedInput.price * 0.5;
const maxPrice = selectedInput.price * 19;
if (price < minPrice) {
isDisabled = true;
disabledReason = 'Слишком дешёвый';
} else if (price > maxPrice) {
isDisabled = true;
disabledReason = 'Слишком дорогой';
}
}
const safeName = item.name.replace(/'/g, "\\'");
const safeUrl = imageUrl.replace(/'/g, "\\'");
return `
<div class="target-item-card ${isDisabled ? 'disabled' : ''}"
onclick="selectTargetItem(${item.id}, '${safeName}', ${price}, '${safeUrl}', '${item.rarity || 'Unknown'}')"
data-price="${price}"
title="${isDisabled ? disabledReason : ''}">
<img src="${imageUrl}" onerror="this.src='/static/placeholder.png'">
<div class="target-item-info">
<div class="target-item-name" title="${item.name}">${item.name}</div>
<div class="target-item-meta">
<span>${item.rarity || ''}</span>
<span class="target-item-price">${price.toLocaleString()} ₽</span>
</div>
</div>
</div>
`;
}).join('');
if (selectedTarget) {
document.querySelectorAll('.target-item-card').forEach(card => {
if (card.onclick && card.onclick.toString().includes(`selectTargetItem(${selectedTarget.item_id}`)) {
card.classList.add('selected');
}
});
}
}
function selectTargetItem(id, name, price, imageUrl, rarity) {
document.querySelectorAll('.target-item-card').forEach(c => c.style.borderColor = 'transparent'); const card = event.target.closest('.target-item-card'); if (card) card.style.borderColor = 'var(--primary)';
selectedTarget = { item_id: id, name, price, image_url: imageUrl, rarity };
document.getElementById('displayTargetImage').src = imageUrl||'/static/placeholder.png';
document.getElementById('displayTargetName').textContent = name.substring(0,25);
if (isSpinning) return;
const card = event.target.closest('.target-item-card');
if (card.classList.contains('disabled')) {
alert('Этот предмет не подходит!');
return;
}
document.querySelectorAll('.target-item-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
selectedTarget = {
item_id: id,
name: name,
price: price,
image_url: imageUrl,
rarity: rarity
};
document.getElementById('displayTargetImage').src = imageUrl || '/static/placeholder.png';
document.getElementById('displayTargetName').textContent = name.substring(0, 25);
document.getElementById('displayTargetPrice').textContent = `${price.toLocaleString()} ₽`;
document.getElementById('selectedTargetDisplay').classList.add('selected');
updateProbability();
}
async function updateProbability() {
if (!selectedInput || !selectedTarget) return;
const fd = new FormData(); fd.append('input_inventory_id', selectedInput.inventory_id); fd.append('target_item_id', selectedTarget.item_id);
try { const r = await fetch('/web/api/upgrade/calculate', { method:'POST', body:fd }); const d = await r.json(); if (d.success) { document.getElementById('wheelProbability').textContent = `${d.adjusted_probability.toFixed(1)}%`; document.getElementById('upgradeWheel').style.setProperty('--probability', d.adjusted_probability); document.getElementById('upgradeBtn').disabled = false; } } catch(e) {}
const formData = new FormData();
formData.append('input_inventory_id', selectedInput.inventory_id);
formData.append('target_item_id', selectedTarget.item_id);
try {
const response = await fetch('/web/api/upgrade/calculate', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
const prob = data.adjusted_probability.toFixed(1);
document.getElementById('wheelProbability').textContent = `${prob}%`;
const wheel = document.getElementById('upgradeWheel');
wheel.style.setProperty('--probability', prob);
document.getElementById('upgradeBtn').disabled = false;
}
} catch (e) {}
}
function sparkles(container) {
for (let i = 0; i < 24; i++) { const s = document.createElement('div'); s.className = 'sparkle'; s.style.cssText = `position:absolute;width:${2+Math.random()*5}px;height:${2+Math.random()*5}px;border-radius:50%;background:${['#f59e0b','#10b981','#ffd700','#fff','#ff6b6b'][Math.floor(Math.random()*5)]};left:${Math.random()*100}%;top:${Math.random()*100}%;pointer-events:none;animation:sparkleFloat 0.8s ease forwards;`; s.style.setProperty('--dx', (Math.random()-0.5)*140+'px'); s.style.setProperty('--dy', -(Math.random()*120+50)+'px'); container.style.position = 'relative'; container.appendChild(s); }
for (let i = 0; i < 24; i++) {
const s = document.createElement('div');
s.className = 'sparkle';
s.style.left = Math.random() * 100 + '%';
s.style.top = Math.random() * 100 + '%';
s.style.setProperty('--dx', (Math.random() - 0.5) * 140 + 'px');
s.style.setProperty('--dy', -(Math.random() * 120 + 50) + 'px');
s.style.animationDelay = Math.random() * 0.6 + 's';
s.style.width = (2 + Math.random() * 5) + 'px';
s.style.height = s.style.width;
s.style.background = ['#f59e0b', '#10b981', '#ffd700', '#fff', '#ff6b6b'][Math.floor(Math.random() * 5)];
container.appendChild(s);
}
}
async function refreshBalance() {
try { const r = await fetch('/web/api/user/balance'); const d = await r.json(); if (d.success) document.querySelectorAll('.user-balance').forEach(el => el.textContent = `${Math.floor(d.balance).toLocaleString()} ₽`); } catch(e) {}
try {
const r = await fetch('/web/api/user/balance');
const d = await r.json();
if (d.success) {
document.querySelectorAll('.user-balance').forEach(el => {
el.textContent = `${Math.floor(d.balance).toLocaleString()} ₽`;
});
}
} catch (e) {}
}
async function refreshHistory() {
try { const r = await fetch('/web/api/upgrade/history'); const d = await r.json(); if (d.success) renderHistory(d.history); } catch(e) {}
try {
const r = await fetch('/web/api/upgrade/history');
const d = await r.json();
if (d.success) renderHistory(d.history);
} catch (e) {}
}
function renderHistory(items) {
const grid = document.getElementById('historyGrid'), count = document.getElementById('historyCount');
const grid = document.getElementById('historyGrid');
const count = document.getElementById('historyCount');
if (count) count.textContent = items.length;
if (!items.length) { grid.innerHTML = '<div class="text-dim text-sm text-center" style="grid-column:1/-1;padding:1rem;">История пуста</div>'; return; }
grid.innerHTML = items.map(u => `<div class="card" style="padding:0.5rem 0.75rem;display:flex;align-items:center;gap:0.5rem;${u.success ? 'border-color:rgba(34,197,94,0.2);' : 'opacity:0.6;'}">
${u.success ? `<img src="${u.input_image_url||'/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" style="width:28px;height:22px;object-fit:contain;"><div style="flex:1;min-width:0;"><div class="text-xs truncate">${(u.input_item_name||'').substring(0,12)}… → ${(u.target_item_name||'').substring(0,12)}…</div><div class="text-xs text-dim">${u.probability}% <span style="color:var(--success)"></span></div></div><img src="${u.target_image_url||'/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" style="width:28px;height:22px;object-fit:contain;">`
: `<img src="${u.target_image_url||'/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" style="width:28px;height:22px;object-fit:contain;opacity:0.5;"><div style="flex:1;min-width:0;"><div class="text-xs truncate">${(u.input_item_name||'').substring(0,12)}… → ${(u.target_item_name||'').substring(0,12)}…</div><div class="text-xs text-dim">${u.probability}% <span style="color:var(--danger)"></span></div></div>`}
</div>`).join('');
if (!items.length) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1;text-align:center;padding:1rem;color:var(--text-secondary)">История пуста</div>';
return;
}
grid.innerHTML = items.map(u => `
<div class="history-card ${u.success ? 'success' : 'fail'}">
${u.success
? `<img src="${u.input_image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" loading="lazy">
<div class="history-info">
<div class="history-names">${(u.input_item_name || '').substring(0, 12)}… → ${(u.target_item_name || '').substring(0, 12)}…</div>
<div class="history-meta">
<span>${u.probability}%</span>
<span class="history-result success"></span>
</div>
</div>
<img src="${u.target_image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" loading="lazy" style="width:32px;height:28px;">`
: `<img src="${u.target_image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" loading="lazy" style="opacity:0.5;">
<div class="history-info">
<div class="history-names">${(u.input_item_name || '').substring(0, 12)}… → ${(u.target_item_name || '').substring(0, 12)}…</div>
<div class="history-meta">
<span>${u.probability}%</span>
<span class="history-result fail"></span>
</div>
</div>`
}
</div>
`).join('');
}
function showToast(message, type) {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
async function executeUpgrade() {
if (!selectedInput || !selectedTarget || isSpinning) return;
const lockedInputId = selectedInput.inventory_id, lockedTargetId = selectedTarget.item_id;
const fd = new FormData(); fd.append('input_inventory_id', lockedInputId); fd.append('target_item_id', lockedTargetId); fd.append('quick_mode', quickMode ? 'true' : 'false');
// Lock the chosen items at spin start
const lockedInputId = selectedInput.inventory_id;
const lockedTargetId = selectedTarget.item_id;
const lockedPrice = selectedInput.price;
const formData = new FormData();
formData.append('input_inventory_id', lockedInputId);
formData.append('target_item_id', lockedTargetId);
formData.append('quick_mode', quickMode ? 'true' : 'false');
const btn = document.getElementById('upgradeBtn');
isSpinning = true; btn.disabled = true; btn.textContent = '🎰 Крутим…';
isSpinning = true;
btn.disabled = true;
btn.textContent = '🎰 Крутим…';
const pointer = document.getElementById('pointerContainer');
pointer.style.transition = 'none'; pointer.style.transform = 'rotate(0deg)'; void pointer.offsetHeight;
pointer.style.transition = 'none';
pointer.style.transform = 'rotate(0deg)';
void pointer.offsetHeight;
try {
const r = await fetch('/web/api/upgrade/execute', { method:'POST', body:fd }); const data = await r.json();
const response = await fetch('/web/api/upgrade/execute', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
if (typeof handleAchievements === 'function') handleAchievements(data);
const spinDuration = quickMode ? 4 : 12;
pointer.style.transition = `transform ${spinDuration}s cubic-bezier(0.4,0,0.15,1.0)`;
pointer.style.transition = `transform ${spinDuration}s cubic-bezier(0.4, 0.0, 0.15, 1.0)`;
pointer.style.transform = `rotate(${data.final_angle}deg)`;
let tickLastDeg = 0, tickCumulative = 0, tickNextThreshold = 15, tickLastTickCumulative = 0, tickControlPlayed = false;
const tickSlowStart = data.final_angle - 90, tickStartTime = performance.now(), tickMaxDuration = spinDuration*1000+500;
// Тики колеса: быстрая фаза каждые 15°, медленная (последние 90°) — 30°
let tickLastDeg = 0, tickCumulative = 0, tickNextThreshold = 15;
let tickLastTickCumulative = 0;
let tickControlPlayed = false;
const tickSlowStart = data.final_angle - 90;
const tickStartTime = performance.now();
const tickMaxDuration = spinDuration * 1000 + 500;
let tickRAF = null;
function tickLoop() {
const elapsed = performance.now() - tickStartTime;
if (elapsed > tickMaxDuration) return;
const style = window.getComputedStyle(pointer);
let deg = 0; const tf = style.transform;
let deg = 0;
const tf = style.transform;
if (tf && tf !== 'none') {
const m = tf.match(/matrix\(([^)]+)\)/); if (m) { const v = m[1].split(', ').map(Number); deg = Math.atan2(v[1], v[0]) * (180/Math.PI); }
else { const m2 = tf.match(/rotate\(([-\d.]+)deg\)/); if (m2) deg = parseFloat(m2[1]); }
const m = tf.match(/matrix\(([^)]+)\)/);
if (m) {
const v = m[1].split(', ').map(Number);
deg = Math.atan2(v[1], v[0]) * (180 / Math.PI);
} else {
const m2 = tf.match(/rotate\(([-\d.]+)deg\)/);
if (m2) deg = parseFloat(m2[1]);
}
}
let delta = deg - tickLastDeg;
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
tickCumulative += delta;
tickLastDeg = deg;
if (tickCumulative >= tickNextThreshold) {
SoundManager.wheelSpin();
tickLastTickCumulative = tickCumulative;
tickNextThreshold += tickCumulative < tickSlowStart ? 15 : 30;
}
// Контрольный тик: если до финала <10°, а последний тик был >15° назад
const remaining = data.final_angle - tickCumulative;
const gap = tickCumulative - tickLastTickCumulative;
if (remaining < 10 && remaining > 0 && gap > 15 && !tickControlPlayed) {
SoundManager.wheelSpin();
tickControlPlayed = true;
}
let delta = deg - tickLastDeg; if (delta > 180) delta -= 360; if (delta < -180) delta += 360;
tickCumulative += delta; tickLastDeg = deg;
if (tickCumulative >= tickNextThreshold) { SoundManager.wheelTick(); tickLastTickCumulative = tickCumulative; tickNextThreshold += tickCumulative < tickSlowStart ? 15 : 30; }
const remaining = data.final_angle - tickCumulative, gap = tickCumulative - tickLastTickCumulative;
if (remaining < 10 && remaining > 0 && gap > 15 && !tickControlPlayed) { SoundManager.wheelTick(); tickControlPlayed = true; }
tickRAF = requestAnimationFrame(tickLoop);
}
tickRAF = requestAnimationFrame(tickLoop);
let resultShown = false;
const onTransitionEnd = () => { pointer.removeEventListener('transitionend', onTransitionEnd); if (tickRAF) cancelAnimationFrame(tickRAF); showResult(); };
const onTransitionEnd = () => {
pointer.removeEventListener('transitionend', onTransitionEnd);
if (tickRAF) cancelAnimationFrame(tickRAF);
showResult();
};
pointer.addEventListener('transitionend', onTransitionEnd);
const showResult = () => {
if (resultShown) return; resultShown = true;
if (resultShown) return;
resultShown = true;
if (data.upgrade_success && data.received_item) {
document.getElementById('displayTargetImage').src = data.received_item.image_url||'/static/placeholder.png';
document.getElementById('displayTargetName').textContent = data.received_item.name.substring(0,25);
document.getElementById('displayTargetImage').src = data.received_item.image_url || '/static/placeholder.png';
document.getElementById('displayTargetName').textContent = data.received_item.name.substring(0, 25);
document.getElementById('displayTargetPrice').textContent = `${Math.floor(data.received_item.price).toLocaleString()} ₽`;
SoundManager.wheelWin(); showNotification('🎉 ' + data.received_item.name, 'success');
document.getElementById('selectedTargetDisplay').style.borderColor = '#10b981';
document.getElementById('selectedTargetDisplay').style.boxShadow = '0 0 20px rgba(16,185,129,0.3)';
showToast(`${getRandomPhrase('win')} ${data.received_item.name}`, 'success');
SoundManager.wheelWin();
} else {
SoundManager.wheelLose(); showNotification('😔 Не повезло', 'error');
document.getElementById('selectedInputDisplay').style.borderColor = '#ef4444';
document.getElementById('selectedInputDisplay').style.boxShadow = '0 0 20px rgba(239,68,68,0.3)';
showToast(getRandomPhrase('lose'), 'fail');
SoundManager.wheelLose();
}
refreshBalance();
setTimeout(() => {
document.getElementById('selectedInputDisplay').style.borderColor = '';
document.getElementById('selectedInputDisplay').style.boxShadow = '';
document.getElementById('selectedTargetDisplay').style.borderColor = '';
document.getElementById('selectedTargetDisplay').style.boxShadow = '';
inventoryItems = inventoryItems.filter(i => i.id !== lockedInputId);
selectedInput = null; selectedTarget = null;
selectedInput = null;
selectedTarget = null;
document.getElementById('selectedInputDisplay').classList.remove('selected');
document.getElementById('selectedTargetDisplay').classList.remove('selected');
document.getElementById('displayInputImage').src = '/static/placeholder.png';
document.getElementById('displayInputName').textContent = 'Не выбрано';
document.getElementById('displayInputPrice').textContent = '—';
@@ -341,23 +738,87 @@
document.getElementById('displayTargetName').textContent = 'Не выбрано';
document.getElementById('displayTargetPrice').textContent = '—';
document.getElementById('wheelProbability').textContent = '0%';
document.getElementById('upgradeWheel').style.setProperty('--probability','0');
btn.disabled = true; btn.textContent = '🎲 Апгрейд'; isSpinning = false;
document.getElementById('upgradeWheel').style.setProperty('--probability', '0');
btn.disabled = true;
btn.textContent = '🎲 Апгрейд';
isSpinning = false;
refreshInventoryAjax();
refreshHistory();
pointer.style.transition = 'none'; pointer.style.transform = 'rotate(0deg)';
pointer.style.transition = 'none';
pointer.style.transform = 'rotate(0deg)';
}, 2000);
};
setTimeout(showResult, spinDuration*1000+300);
} else { alert(data.error||'Ошибка'); isSpinning = false; btn.disabled = false; btn.textContent = '🎲 Апгрейд'; }
} catch(e) { alert('Ошибка соединения'); isSpinning = false; btn.disabled = false; btn.textContent = '🎲 Апгрейд'; }
// Запасной таймер на случай если transitionend не сработает
setTimeout(showResult, spinDuration * 1000 + 300);
} else {
alert(data.error || 'Ошибка');
isSpinning = false;
btn.disabled = false;
btn.textContent = '🎲 Апгрейд';
}
} catch (e) {
alert('Ошибка соединения');
isSpinning = false;
btn.disabled = false;
btn.textContent = '🎲 Апгрейд';
}
}
function handleAchievements(data) { if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) data.achievements_unlocked.forEach(ach => showAchievementPopup(ach.title||ach.name||'Достижение', ach.reward||'')); }
async function refreshInventoryAjax() {
try {
const resp = await fetch('/web/api/inventory/items');
const serverItems = await resp.json();
// Enrich with price/image from server data
inventoryItems = serverItems.map(item => {
const img = item.image_url || (item.item_id ? `/static/placeholder.png` : '/static/placeholder.png');
return {
id: item.id,
item_id: item.item_id,
name: item.name,
rarity: item.rarity,
price_rub: item.price || item.price_rub || 100,
image_url: img,
wear: item.wear || '',
float: item.float || item.float_value || 0
};
});
renderInventory();
} catch (e) {
console.error('Failed to refresh inventory:', e);
renderInventory();
}
}
async function logout() {
await fetch('/web/api/auth/logout', { method: 'POST' });
window.location.href = '/';
}
</script>
<script src="/static/js/sounds.js"></script>
<script src="/static/js/websocket.js"></script>
<script src="/static/js/safemode.js"></script>
<script>
function handleAchievements(data) {
if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) {
data.achievements_unlocked.forEach(ach => {
const title = ach.title || ach.name || 'Достижение';
const reward = ach.reward || '';
showAchievementPopup(title, reward);
});
}
}
</script>
<script>
function toggleSound() { const e = SoundManager.toggle(); document.getElementById('soundToggle').textContent = e ? '🔊' : '🔇'; }
document.addEventListener('DOMContentLoaded', () => { const btn = document.getElementById('soundToggle'); if(btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇'; });
function toggleSound() {
const enabled = SoundManager.toggle();
document.getElementById('soundToggle').textContent = enabled ? '🔊' : '🔇';
}
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('soundToggle');
if (btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
});
</script>
{% if user %}{% include '_activity_sidebar.html' %}{% endif %}
</body>
</html>
</html>