chore: initial commit — CS2 Simulator
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
<style>
|
||||
.activity-sidebar {
|
||||
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-header {
|
||||
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::-webkit-scrollbar { width: 3px; }
|
||||
.activity-sidebar-list::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 2px; }
|
||||
.activity-sidebar-item {
|
||||
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-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-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">
|
||||
<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()" title="Скрыть">✕</button>
|
||||
</div>
|
||||
<div class="activity-sidebar-list" id="activitySidebarList">
|
||||
<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>
|
||||
|
||||
<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);
|
||||
}
|
||||
} catch (e) {
|
||||
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;
|
||||
const empty = list.querySelector('div[style*="text-align:center"]');
|
||||
if (empty) empty.remove();
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const RARITY_COLORS = {
|
||||
'consumer-grade': '#b0b0b0', 'industrial-grade': '#5e98d9',
|
||||
'mil-spec': '#4b69ff', 'mil-spec-grade': '#4b69ff',
|
||||
'restricted': '#8847ff', 'classified': '#d32ce6',
|
||||
'covert': '#eb4b4b', 'rare-special-item': '#ffd700',
|
||||
'rare-special': '#ffd700', 'extraordinary': '#ffd700',
|
||||
'contraband': '#ffaa00'
|
||||
};
|
||||
|
||||
function createActivityItem(act) {
|
||||
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) {
|
||||
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>
|
||||
<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-secondary);font-size:0.85rem;">Пока нет активностей</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = '';
|
||||
activities.forEach(a => list.appendChild(createActivityItem(a)));
|
||||
}
|
||||
|
||||
function toggleActivitySidebar() {
|
||||
const s = document.getElementById('activitySidebar');
|
||||
const btn = document.getElementById('activityToggleBtn');
|
||||
if (!s) return;
|
||||
const isOpen = !s.classList.contains('closed');
|
||||
s.classList.toggle('closed', isOpen);
|
||||
document.body.classList.toggle('sidebar-closed', isOpen);
|
||||
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>
|
||||
@@ -0,0 +1,323 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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 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">Инвентарь</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="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="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="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="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="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>
|
||||
</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('.category-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.category-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const cat = btn.dataset.cat;
|
||||
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>
|
||||
</html>
|
||||
@@ -0,0 +1,245 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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 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="/inventory" 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="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-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="activity-empty">
|
||||
<div class="big-icon">📭</div>
|
||||
<h3>Пока нет активностей</h3>
|
||||
<p>Открой кейс или выполни контракт — это появится здесь в реальном времени!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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', () => {
|
||||
// Подключаемся к WS и слушаем новые активности
|
||||
WS.on('activity', (data) => {
|
||||
const act = data.activity;
|
||||
if (!act) return;
|
||||
|
||||
const feed = document.getElementById('activityFeed');
|
||||
|
||||
// Убираем empty state если есть
|
||||
const empty = feed.querySelector('.activity-empty');
|
||||
if (empty) empty.remove();
|
||||
|
||||
const iconMap = {
|
||||
'case_open': '📦',
|
||||
'contract': '🔄',
|
||||
'upgrade': '🆙',
|
||||
'achievement': '🏆',
|
||||
'crash': '💥'
|
||||
};
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'activity-item';
|
||||
item.style.animation = 'none';
|
||||
item.offsetHeight;
|
||||
item.style.animation = 'fadeInUp 0.3s ease';
|
||||
item.innerHTML = `
|
||||
<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);
|
||||
|
||||
// Ограничиваем до 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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,361 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Админ-панель{% endblock %} — CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
*, *::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>
|
||||
|
||||
<div class="admin-sidebar">
|
||||
<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>
|
||||
</nav>
|
||||
<div class="sb-footer">
|
||||
<a href="/">← <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>
|
||||
<div class="admin-content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block modals %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,361 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% 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 content %}
|
||||
<div class="a-card">
|
||||
<div class="a-card-header">📂 Секции главной страницы</div>
|
||||
<div id="mainpageSections">
|
||||
{% for section in mainpage %}
|
||||
<div class="a-card" style="margin-bottom:0.75rem;padding:1rem" data-tab="{{ section.tab }}">
|
||||
<div class="a-flex" style="justify-content:space-between">
|
||||
<strong style="font-size:0.9rem">{{ section.tab }}</strong>
|
||||
<div class="td-actions">
|
||||
<button class="a-btn a-btn-sm" onclick="openAddCaseModal('{{ section.tab }}')">➕ Добавить кейс</button>
|
||||
<button class="a-btn a-btn-danger a-btn-sm" onclick="deleteSection('{{ section.tab }}')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-flex" style="flex-wrap:wrap;gap:0.5rem;margin-top:0.5rem">
|
||||
{% for case_name in section.cases %}
|
||||
<span style="background:rgba(255,255,255,0.04);border-radius:6px;padding:0.2rem 0.5rem;font-size:0.78rem;display:flex;align-items:center;gap:0.3rem">
|
||||
🎲 {{ case_name }}
|
||||
<span onclick="removeCaseFromSection('{{ section.tab }}','{{ case_name }}')" style="cursor:pointer;color:#ef4444;font-size:0.7rem">✕</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button class="a-btn a-mt" onclick="openAddSectionModal()">➕ Добавить секцию</button>
|
||||
</div>
|
||||
|
||||
<div class="a-card">
|
||||
<div class="a-card-header">🎲 Все кейсы</div>
|
||||
<div class="a-table-wrap">
|
||||
<table class="a-table">
|
||||
<thead><tr><th>Название</th><th>Цена</th><th>Тип</th><th>Предметов</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in cases %}
|
||||
<tr>
|
||||
<td><strong>{{ c.name }}</strong></td>
|
||||
<td>{{ c.price_open }} ₽</td>
|
||||
<td style="font-size:0.75rem">{{ c.display_name|default('') }}</td>
|
||||
<td>{{ c.items_count }}</td>
|
||||
<td class="td-actions">
|
||||
<button class="a-btn a-btn-sm" onclick="openEditCaseModal('{{ c.name }}')">✏️</button>
|
||||
<button class="a-btn a-btn-danger a-btn-sm" onclick="deleteCase('{{ c.name }}')">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
<!-- Create/Edit Case Modal -->
|
||||
<div class="a-overlay" id="caseModal">
|
||||
<div class="a-modal" style="max-width:600px">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<div class="a-flex a-mt" style="flex-wrap:wrap">
|
||||
<input type="text" id="caseItemSearch" class="a-input a-input-sm" style="flex:1;min-width:120px" placeholder="Поиск предмета..." oninput="searchCaseItems()">
|
||||
<button class="a-btn a-btn-sm" onclick="openAllItemsModal()">📦 Все предметы</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-modal-actions">
|
||||
<button class="a-btn" onclick="closeModal('caseModal')">Отмена</button>
|
||||
<button class="a-btn a-btn-primary" id="saveCaseBtn" onclick="saveCase()">💾 Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All items browser modal -->
|
||||
<div class="a-overlay" id="allItemsModal">
|
||||
<div class="a-modal" style="max-width:700px;max-height:80vh;display:flex;flex-direction:column">
|
||||
<div class="a-modal-title">📦 Все предметы</div>
|
||||
<input type="text" id="allItemsSearch" class="a-input a-input-sm a-mb" placeholder="Поиск..." oninput="searchAllItems()">
|
||||
<div id="allItemsGrid" style="flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.5rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add case to section modal -->
|
||||
<div class="a-overlay" id="addCaseModal">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Add section modal -->
|
||||
<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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.ci-item { display:flex; align-items:center; gap:0.5rem; padding:0.35rem 0; border-bottom:1px solid rgba(255,255,255,0.03); font-size:0.78rem; }
|
||||
.ci-item img { width:32px; height:24px; object-fit:contain; }
|
||||
.ci-item .ci-info { flex:1; }
|
||||
.ci-item .ci-rarity { font-size:0.65rem; color:rgba(255,255,255,0.35); }
|
||||
.all-item-card { background:rgba(0,0,0,0.15); border-radius:6px; padding:0.35rem; cursor:pointer; text-align:center; font-size:0.7rem; transition:all 0.15s; border:1px solid transparent; }
|
||||
.all-item-card:hover { border-color:rgba(245,158,11,0.3); background:rgba(245,158,11,0.05); }
|
||||
.all-item-card img { width:100%; height:40px; object-fit:contain; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
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 || [];
|
||||
});
|
||||
|
||||
// ─── 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');
|
||||
}
|
||||
|
||||
function openEditCaseModal(caseName) {
|
||||
editingCaseName = caseName;
|
||||
document.getElementById('caseModalTitle').textContent = '✏️ Редактировать кейс';
|
||||
document.getElementById('saveCaseBtn').textContent = '💾 Сохранить';
|
||||
document.getElementById('editCaseName').value = caseName;
|
||||
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() {
|
||||
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();
|
||||
}
|
||||
|
||||
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,'"')})">
|
||||
<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>';
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
const isEdit = editingCaseName !== null && editingCaseName !== name;
|
||||
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 || 'Ошибка');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── 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,'"')})">
|
||||
<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');
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
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 %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Дашборд — Админ-панель{% endblock %}
|
||||
{% block nav_dashboard %}active{% endblock %}
|
||||
{% block page_title %}📊 Дашборд{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="a-stats">
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Пользователей</div>
|
||||
<div class="as-value">{{ total_users }}</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">{{ total_openings }}</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_contracts }}</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_items }}</div>
|
||||
<div class="as-bar" style="width:75%;background:#f472b6"></div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Общий баланс</div>
|
||||
<div class="as-value">{{ "%.0f"|format(total_balance) }} ₽</div>
|
||||
<div class="as-bar" style="width:40%;background:#34d399"></div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Средний РПУ</div>
|
||||
<div class="as-value">{{ avg_rpu }}%</div>
|
||||
<div class="as-bar" style="width:{{ avg_rpu }}%;background:#fbbf24"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a-row a-mb">
|
||||
<div class="a-card" style="flex:1">
|
||||
<div class="a-card-header">🎲 RPU Overview</div>
|
||||
{% if rpu_count > 0 %}
|
||||
<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" 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 %}
|
||||
<div class="a-empty"><div class="a-empty-icon">📭</div>Нет данных RPU</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,294 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}{{ target_user.username }} — Админ-панель{% endblock %}
|
||||
{% block nav_users %}active{% endblock %}
|
||||
{% block page_title %}
|
||||
👤 {{ 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 %}
|
||||
{% block top_actions %}
|
||||
<a href="/admin/users" class="at-back">← Назад</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Предметов</div>
|
||||
<div class="as-value">{{ inventory|length }}</div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Открытий кейсов</div>
|
||||
<div class="as-value">{{ total_openings }}</div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Контрактов</div>
|
||||
<div class="as-value">{{ total_contracts }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RPU -->
|
||||
<div class="a-card">
|
||||
<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="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="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>
|
||||
{% 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 "✅ Разбанить" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Инвентарь -->
|
||||
<div class="a-card">
|
||||
<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>
|
||||
<tbody>
|
||||
{% for item in inventory %}
|
||||
<tr>
|
||||
<td>{{ item.id }}</td>
|
||||
<td>{{ item.market_hash_name[:35] }}{{'…' if item.market_hash_name|length > 35}}</td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
<!-- Баланс -->
|
||||
<div class="a-overlay" id="balanceModal">
|
||||
<div class="a-modal">
|
||||
<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">
|
||||
<label class="a-label">Сумма</label>
|
||||
<input type="number" id="balanceAmount" class="a-input" min="0" step="100" value="1000">
|
||||
</div>
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
<div class="a-modal-actions">
|
||||
<button class="a-btn" onclick="closeModal('balanceModal')">Отмена</button>
|
||||
<button class="a-btn a-btn-primary" onclick="updateBalance()">Применить</button>
|
||||
</div>
|
||||
</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-mb">
|
||||
<label class="a-label">Поиск</label>
|
||||
<input type="text" id="itemSearchInput" class="a-input" placeholder="Введите название..." oninput="searchItems()">
|
||||
</div>
|
||||
<div id="itemSearchResults" style="max-height:300px;overflow-y:auto"></div>
|
||||
<div class="a-modal-actions">
|
||||
<button class="a-btn" onclick="closeModal('giveItemModal')">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RPU -->
|
||||
<div class="a-overlay" id="rpuModal">
|
||||
<div class="a-modal" style="max-width:500px">
|
||||
<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"> 🔄 Автоматическая подкрутка
|
||||
</label>
|
||||
</div>
|
||||
<div class="a-modal-actions">
|
||||
<button class="a-btn" onclick="closeModal('rpuModal')">Отмена</button>
|
||||
<button class="a-btn a-btn-primary" onclick="saveRPU()">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.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 {
|
||||
display:flex; align-items:center; gap:0.75rem;
|
||||
padding:0.5rem; border-bottom:1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
#itemSearchResults > div img { width:40px; height:32px; object-fit:contain; }
|
||||
#itemSearchResults > div .ir-info { flex:1; }
|
||||
#itemSearchResults > div .ir-info .ir-name { font-size:0.82rem; }
|
||||
#itemSearchResults > div .ir-info .ir-meta { font-size:0.7rem; color:rgba(255,255,255,0.35); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const userId = {{ target_user.id }};
|
||||
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();
|
||||
fd.append('amount', document.getElementById('balanceAmount').value);
|
||||
fd.append('operation', document.getElementById('balanceOperation').value);
|
||||
const res = await fetch(`/admin/api/user/${userId}/balance`, { method:'POST', body:fd });
|
||||
const d = await res.json();
|
||||
if (d.success) { alert(d.message); window.location.reload(); }
|
||||
else alert(d.error);
|
||||
}
|
||||
|
||||
// ====== GIVE ITEM ======
|
||||
function openGiveItemModal() { openModal('giveItemModal'); searchItems(); }
|
||||
async function searchItems() {
|
||||
const q = document.getElementById('itemSearchInput').value;
|
||||
const res = await fetch(`/admin/api/items/search?q=${encodeURIComponent(q)}`);
|
||||
const items = await res.json();
|
||||
document.getElementById('itemSearchResults').innerHTML = items.map(item => `
|
||||
<div>
|
||||
<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} • ${item.price_rub || 0} ₽</div>
|
||||
</div>
|
||||
<button class="a-btn a-btn-primary a-btn-sm" onclick="giveItem(${item.id})">Выдать</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
async function giveItem(itemId) {
|
||||
const fd = new FormData(); fd.append('item_id', itemId);
|
||||
const res = await fetch(`/admin/api/user/${userId}/give-item`, { method:'POST', body:fd });
|
||||
const d = await res.json();
|
||||
if (d.success) { alert(d.message); closeModal('giveItemModal'); window.location.reload(); }
|
||||
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' });
|
||||
const d = await res.json();
|
||||
if (d.success) window.location.reload();
|
||||
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' });
|
||||
const d = await res.json();
|
||||
if (d.success) window.location.reload(); else alert(d.error);
|
||||
}
|
||||
async function toggleBan() {
|
||||
if (!confirm('{{ "Забанить" if not target_user.is_banned else "Разбанить" }}?')) return;
|
||||
const res = await fetch(`/admin/api/user/${userId}/toggle-ban`, { method:'POST' });
|
||||
const d = await res.json();
|
||||
if (d.success) window.location.reload(); else alert(d.error);
|
||||
}
|
||||
|
||||
// ====== RPU ======
|
||||
async function loadRPU() {
|
||||
try {
|
||||
const res = await fetch(`/admin/api/user/${userId}/rpu`);
|
||||
const d = await res.json();
|
||||
currentRPU = d;
|
||||
const lv = Math.round(d.rpu_level);
|
||||
document.getElementById('rpuLevel').textContent = lv + '%';
|
||||
document.getElementById('rpuLevel').style.color = lv < 40 ? '#22c55e' : lv > 60 ? '#ef4444' : '#f59e0b';
|
||||
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 ? '✅' : '❌';
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
function openRPUModal() {
|
||||
const sliders = document.getElementById('rpuSliders');
|
||||
const rarities = [
|
||||
['consumer','Consumer Grade'],['industrial','Industrial Grade'],
|
||||
['mil_spec','Mil-Spec'],['restricted','Restricted'],
|
||||
['classified','Classified'],['covert','Covert'],['rare_special','Rare Special']
|
||||
];
|
||||
let html = '';
|
||||
rarities.forEach(([k,l]) => {
|
||||
const v = currentRPU?.multipliers[k] || 1.0;
|
||||
html += `<div class="slider-group"><label><span>${l}</span><span id="rp_${k}_v">${v.toFixed(2)}x</span></label>
|
||||
<input type="range" id="rp_${k}" min="0.1" max="3.0" step="0.05" value="${v}"
|
||||
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>
|
||||
<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;
|
||||
document.getElementById('rpuAutoAdjust').checked = currentRPU?.auto_adjust || false;
|
||||
openModal('rpuModal');
|
||||
}
|
||||
async function saveRPU() {
|
||||
const fd = new FormData();
|
||||
['consumer','industrial','mil_spec','restricted','classified','covert','rare_special'].forEach(k => fd.append(k, document.getElementById('rp_'+k).value));
|
||||
fd.append('luck', document.getElementById('rp_luck').value);
|
||||
fd.append('auto_adjust', document.getElementById('rpuAutoAdjust').checked);
|
||||
const res = await fetch(`/admin/api/user/${userId}/rpu/update`, { method:'POST', body:fd });
|
||||
const d = await res.json();
|
||||
if (d.success) { alert(d.message); closeModal('rpuModal'); loadRPU(); }
|
||||
else alert(d.error);
|
||||
}
|
||||
async function setRPUPreset(preset) {
|
||||
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 });
|
||||
const d = await res.json();
|
||||
if (d.success) { alert(d.message); loadRPU(); } else alert(d.error);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadRPU);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,72 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Пользователи — Админ-панель{% endblock %}
|
||||
{% block nav_users %}active{% endblock %}
|
||||
{% block page_title %}👥 Пользователи{% endblock %}
|
||||
{% block top_actions %}
|
||||
<div class="a-flex">
|
||||
<input type="text" id="userSearch" class="a-input a-input-sm" style="width:200px" placeholder="Поиск по имени..." value="{{ search or '' }}">
|
||||
<button class="a-btn a-btn-primary" onclick="searchUsers()">🔍 Найти</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% 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>
|
||||
<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>{{ "%.0f"|format(u.balance) }} ₽</td>
|
||||
<td>
|
||||
{% if u.is_admin %}<span class="a-badge a-badge-admin">Админ</span>{% endif %}
|
||||
{% if u.is_banned %}<span class="a-badge a-badge-banned">Забанен</span>{% endif %}
|
||||
{% if not u.is_admin and not u.is_banned %}<span class="a-badge a-badge-user">Игрок</span>{% endif %}
|
||||
</td>
|
||||
<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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<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();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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 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="/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="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-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="cases-container">
|
||||
<div class="container">
|
||||
<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 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>
|
||||
</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>
|
||||
<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>
|
||||
{% if user %}{% include '_activity_sidebar.html' %}{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,738 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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 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="/inventory" 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="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-outline" id="safeModeToggle">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<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; }
|
||||
|
||||
/* ─── 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}
|
||||
}
|
||||
|
||||
/* ─── 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;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
{% 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>
|
||||
|
||||
<!-- 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 class="ct-sel-slot" id="ctSlot-{{ i }}" data-idx="{{ i }}">
|
||||
<span class="s-num">{{ i+1 }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- Модалка бумаги -->
|
||||
<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>
|
||||
|
||||
<!-- Модалка результата -->
|
||||
<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;
|
||||
if (first && (it.isKnife || it.rarity !== first.data.rarity || it.type !== first.data.type)) return false;
|
||||
}
|
||||
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 '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 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 (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();
|
||||
}
|
||||
|
||||
function ctUnpick(invId) {
|
||||
if (!selected.has(invId)) return;
|
||||
selected.delete(invId);
|
||||
// Перенумеровать
|
||||
let i = 0;
|
||||
for (const [id, v] of selected) {
|
||||
v.idx = i++;
|
||||
}
|
||||
if (selected.size === 0) 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.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 entry = items[i];
|
||||
if (entry) {
|
||||
const [id, v] = entry;
|
||||
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.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
|
||||
}));
|
||||
window.__ctItems = items;
|
||||
|
||||
const slots = document.getElementById('ctPaperSlots');
|
||||
slots.innerHTML = '';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
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').classList.add('open');
|
||||
document.getElementById('ctStamp').classList.remove('active');
|
||||
setTimeout(ctInitSign, 100);
|
||||
ctAnimateSlots();
|
||||
}
|
||||
|
||||
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.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').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';
|
||||
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(); };
|
||||
const st = () => drawing = false;
|
||||
c.onmousedown = s; c.onmousemove = m; c.onmouseup = st; c.onmouseleave = st;
|
||||
c.ontouchstart = e=>{e.preventDefault();const t=e.touches[0];const r=c.getBoundingClientRect();drawing=true;ctx.beginPath();ctx.moveTo(t.clientX-r.left,t.clientY-r.top);};
|
||||
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);}
|
||||
}
|
||||
|
||||
async function ctSign() {
|
||||
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)));
|
||||
try {
|
||||
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();
|
||||
setTimeout(() => ctShowResult(data.received_item, data.is_knife_contract), 200);
|
||||
} else {
|
||||
SoundManager.error(); alert(data.error || 'Ошибка'); ctClosePaper();
|
||||
}
|
||||
} catch(_) { SoundManager.error(); alert('Ошибка соединения'); ctClosePaper(); }
|
||||
btn.disabled = false; btn.textContent = '✍ ПОДПИСАТЬ';
|
||||
}
|
||||
|
||||
function ctShowResult(item, isKnife) {
|
||||
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="--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').classList.add('open');
|
||||
}
|
||||
|
||||
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 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();
|
||||
});
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,518 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crash - CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
.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 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="/inventory" 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="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="container" style="padding: 2rem 1rem;">
|
||||
<div class="crash-layout">
|
||||
<div class="crash-canvas-container">
|
||||
<canvas id="crashCanvas"></canvas>
|
||||
<div class="crash-overlay">
|
||||
<div class="crash-multiplier waiting" id="crashMultiplier">Ожидание...</div>
|
||||
<div class="crash-status-text" id="crashStatus">Следующий раунд скоро начнётся</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="crash-history" id="crashHistory"></div>
|
||||
|
||||
<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 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="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="crash-players-panel">
|
||||
<h3>👥 Ставки</h3>
|
||||
<div id="playersList">
|
||||
<div style="color: var(--text-secondary); text-align: center; padding: 1rem;">
|
||||
Ожидание ставок...
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
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');
|
||||
|
||||
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; // секунд в окне
|
||||
|
||||
function drawGraph() {
|
||||
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;
|
||||
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) {
|
||||
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) {
|
||||
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();
|
||||
lastPhase = phase;
|
||||
}
|
||||
|
||||
updatePlayers();
|
||||
updateHistory();
|
||||
drawGraph();
|
||||
}
|
||||
|
||||
function updatePlayers() {
|
||||
if (!gameState) return;
|
||||
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="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 => {
|
||||
const isWin = m > 2;
|
||||
return `<span class="crash-history-item ${isWin ? 'win' : 'loss'}">x${m.toFixed(2)}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 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 formData = new FormData();
|
||||
formData.append('amount', amount);
|
||||
|
||||
try {
|
||||
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('Ошибка соединения');
|
||||
}
|
||||
}
|
||||
|
||||
async function cashOut() {
|
||||
if (!myActiveBet || myCashoutMult !== null) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/web/api/crash/cashout', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
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('Ошибка соединения');
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CS2 Trade-Up Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<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-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="hero">
|
||||
<div class="container">
|
||||
<h1>CS2 Trade-Up Simulator</h1>
|
||||
<p class="hero-subtitle">Открывай кейсы, собирай скины и создавай контракты</p>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📦</div>
|
||||
<h3>Открывай кейсы</h3>
|
||||
<p>Испытай удачу и получи редкие скины</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔄</div>
|
||||
<h3>Создавай контракты</h3>
|
||||
<p>Обменивай 10 скинов на один более редкий</p>
|
||||
</div>
|
||||
<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>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,802 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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 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">Инвентарь</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="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="inventory-container">
|
||||
<div class="container">
|
||||
<div class="inventory-header">
|
||||
<div>
|
||||
<h1>🎒 Инвентарь</h1>
|
||||
<p class="page-subtitle">Всего предметов: <span id="totalItemsCount">{{ inventory_items|length }}</span></p>
|
||||
</div>
|
||||
|
||||
<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="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="inventory-grid-detailed" id="inventoryGrid">
|
||||
{% for item in inventory_items %}
|
||||
<div class="inventory-skin-card rarity-{{ item.rarity|lower|replace(' ', '-') }}"
|
||||
data-inventory-id="{{ item.id }}"
|
||||
data-rarity="{{ item.rarity }}"
|
||||
data-type="{{ item.type }}"
|
||||
data-name="{{ item.name|lower }}"
|
||||
data-price="{{ item.price_rub or 100 }}">
|
||||
<input type="checkbox" class="item-select-checkbox"
|
||||
onchange="onItemSelect(this)"
|
||||
data-id="{{ item.id }}"
|
||||
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="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-large">
|
||||
<div class="empty-icon">📭</div>
|
||||
<h3>Ваш инвентарь пуст</h3>
|
||||
<p>Откройте кейсы, чтобы получить первые предметы!</p>
|
||||
<a href="/cases" class="btn btn-primary">Открыть кейсы</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Модальное окно массовой продажи -->
|
||||
<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 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="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>
|
||||
|
||||
<script>
|
||||
let selectedItems = new Set();
|
||||
let itemPrices = new Map();
|
||||
|
||||
// Инициализация цен
|
||||
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('.inventory-skin-card');
|
||||
const itemId = parseInt(checkbox.dataset.id);
|
||||
|
||||
if (checkbox.checked) {
|
||||
selectedItems.add(itemId);
|
||||
card.classList.add('selected-for-sale');
|
||||
} else {
|
||||
selectedItems.delete(itemId);
|
||||
card.classList.remove('selected-for-sale');
|
||||
document.getElementById('selectAllCheckbox').checked = false;
|
||||
}
|
||||
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const selectAll = document.getElementById('selectAllCheckbox');
|
||||
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.classList.add('selected-for-sale');
|
||||
} else {
|
||||
selectedItems.delete(itemId);
|
||||
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} выбрано`;
|
||||
|
||||
let total = 0;
|
||||
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';
|
||||
priceBadge.style.display = 'none';
|
||||
sellBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBalance() {
|
||||
try {
|
||||
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)} ₽`;
|
||||
});
|
||||
}
|
||||
} 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 data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
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 {
|
||||
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;
|
||||
});
|
||||
|
||||
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 data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
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();
|
||||
} 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('.inventory-skin-card').forEach(card => {
|
||||
if (card.style.display !== 'none') {
|
||||
rarities.add(card.dataset.rarity);
|
||||
}
|
||||
});
|
||||
|
||||
// Создаем опции
|
||||
optionsDiv.innerHTML = '';
|
||||
Array.from(rarities).sort().forEach(rarity => {
|
||||
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);
|
||||
});
|
||||
|
||||
// Добавляем опцию "Все"
|
||||
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 toggleAllRarities(checkbox) {
|
||||
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('.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);
|
||||
}
|
||||
});
|
||||
|
||||
const allChecked = document.querySelector('#bulkSellOptions input[value="all"]')?.checked;
|
||||
|
||||
let count = 0;
|
||||
let total = 0;
|
||||
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||
if (card.style.display !== 'none') {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
const allChecked = document.querySelector('#bulkSellOptions input[value="all"]')?.checked;
|
||||
|
||||
const idsToSell = [];
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||
if (card.style.display !== 'none') {
|
||||
const rarity = card.dataset.rarity;
|
||||
if (allChecked || selectedRarities.includes(rarity)) {
|
||||
idsToSell.push(parseInt(card.dataset.inventoryId));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
await refreshBalance();
|
||||
// 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 (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;
|
||||
|
||||
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 && 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('.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();
|
||||
}
|
||||
|
||||
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>
|
||||
<script src="/static/js/safemode.js"></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>
|
||||
// Живое обновление через WebSocket
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
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>
|
||||
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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 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 class="auth-container">
|
||||
<div class="auth-card">
|
||||
<h2>Вход в аккаунт</h2>
|
||||
|
||||
<form id="loginForm" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Имя пользователя</label>
|
||||
<input type="text" id="username" name="username" required minlength="3">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль</label>
|
||||
<input type="password" id="password" name="password" required minlength="4">
|
||||
</div>
|
||||
|
||||
<div id="errorMessage" class="error-message" style="display: none;"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">Войти</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
Нет аккаунта? <a href="/register">Зарегистрируйтесь</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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 data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
window.location.href = data.redirect;
|
||||
} else {
|
||||
errorDiv.textContent = data.error || 'Ошибка входа';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
errorDiv.textContent = 'Ошибка соединения с сервером';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<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>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<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="/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="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-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="profile-container">
|
||||
<div class="container">
|
||||
<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>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ cases_opened }}</div>
|
||||
<div class="stat-label">Открыто кейсов</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ contracts_completed }}</div>
|
||||
<div class="stat-label">Выполнено контрактов</div>
|
||||
</div>
|
||||
{% if not is_public %}
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ "%.2f"|format(user.balance) }} ₽</div>
|
||||
<div class="stat-label">Баланс</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="profile-sections">
|
||||
<div class="section">
|
||||
<h2>🏆 Ценные предметы</h2>
|
||||
<div class="items-grid">
|
||||
{% set target_items = valuable_items %}
|
||||
{% for item in target_items %}
|
||||
<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="empty-state">У {% if is_public %}этого пользователя{% else %}вас{% endif %} пока нет предметов.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📦 Последние открытия</h2>
|
||||
<div class="history-list">
|
||||
{% for opening in recent_openings %}
|
||||
<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="empty-state">Пока нет открытий</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🔄 Последние контракты</h2>
|
||||
<div class="history-list">
|
||||
{% for contract in recent_contracts %}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">Пока нет контрактов</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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 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 class="auth-container">
|
||||
<div class="auth-card">
|
||||
<h2>Регистрация</h2>
|
||||
|
||||
<form id="registerForm" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Имя пользователя</label>
|
||||
<input type="text" id="username" name="username" required minlength="3">
|
||||
<small>Минимум 3 символа</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль</label>
|
||||
<input type="password" id="password" name="password" required minlength="4">
|
||||
<small>Минимум 4 символа</small>
|
||||
</div>
|
||||
|
||||
<div id="errorMessage" class="error-message" style="display: none;"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">Зарегистрироваться</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
Уже есть аккаунт? <a href="/login">Войдите</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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 data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
window.location.href = data.redirect;
|
||||
} else {
|
||||
errorDiv.textContent = data.error || 'Ошибка регистрации';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
errorDiv.textContent = 'Ошибка соединения с сервером';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,824 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes, viewport-fit=cover">
|
||||
<title>Апгрейд — CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body class="upgrade-page">
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<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="/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">{{ "%.0f"|format(user.balance) }} ₽</span>
|
||||
<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-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="upgrade-container">
|
||||
<div class="container">
|
||||
<div class="upgrade-header">
|
||||
<h1>🆙 Апгрейд</h1>
|
||||
<p>Рискни предметом ради более дорогого</p>
|
||||
</div>
|
||||
|
||||
<div class="upgrade-layout">
|
||||
<!-- Inventory -->
|
||||
<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 class="inventory-list" id="inventoryList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Wheel -->
|
||||
<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="selected-item-mini-name" id="displayInputName">Не выбрано</div>
|
||||
<div class="selected-item-mini-price" id="displayInputPrice">—</div>
|
||||
</div>
|
||||
<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="selected-item-mini-name" id="displayTargetName">Не выбрано</div>
|
||||
<div class="selected-item-mini-price" id="displayTargetPrice">—</div>
|
||||
</div>
|
||||
</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 class="upgrade-actions">
|
||||
{% if user %}
|
||||
<button class="upgrade-btn" onclick="executeUpgrade()" id="upgradeBtn" disabled>
|
||||
🎲 Апгрейд
|
||||
</button>
|
||||
<button class="quick-btn" id="quickModeBtn" onclick="toggleQuickMode()" title="Быстрый режим">⚡</button>
|
||||
{% else %}
|
||||
<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="target-panel">
|
||||
<div class="target-header">
|
||||
<div class="panel-title" style="margin-bottom: 0.4rem;">
|
||||
<span>🎯 Цель</span>
|
||||
</div>
|
||||
<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 class="target-list" id="targetList">
|
||||
<div class="empty-state">Выберите предмет</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History -->
|
||||
<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>
|
||||
// === Фразы для побед/поражений ===
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const container = document.getElementById('inventoryList');
|
||||
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 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);
|
||||
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
|
||||
};
|
||||
|
||||
renderInventory();
|
||||
document.getElementById('displayInputImage').src = item.image_url || '/static/placeholder.png';
|
||||
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;
|
||||
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 (selectedInput) {
|
||||
const inputPrice = selectedInput.price;
|
||||
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 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 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('.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() {
|
||||
const query = document.getElementById('targetSearch').value;
|
||||
currentSearchQuery = query;
|
||||
const container = document.getElementById('targetList');
|
||||
|
||||
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 === 0) {
|
||||
container.innerHTML = '<div class="empty-state">Ничего не найдено</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = items.map(item => {
|
||||
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) {
|
||||
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 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.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) {}
|
||||
}
|
||||
|
||||
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) {}
|
||||
}
|
||||
|
||||
function renderHistory(items) {
|
||||
const grid = document.getElementById('historyGrid');
|
||||
const count = document.getElementById('historyCount');
|
||||
if (count) count.textContent = items.length;
|
||||
|
||||
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;
|
||||
|
||||
// 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 = '🎰 Крутим…';
|
||||
|
||||
const pointer = document.getElementById('pointerContainer');
|
||||
pointer.style.transition = 'none';
|
||||
pointer.style.transform = 'rotate(0deg)';
|
||||
void pointer.offsetHeight;
|
||||
|
||||
try {
|
||||
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, 0.15, 1.0)`;
|
||||
pointer.style.transform = `rotate(${data.final_angle}deg)`;
|
||||
|
||||
// Тики колеса: быстрая фаза каждые 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;
|
||||
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]);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
tickRAF = requestAnimationFrame(tickLoop);
|
||||
}
|
||||
tickRAF = requestAnimationFrame(tickLoop);
|
||||
|
||||
let resultShown = false;
|
||||
const onTransitionEnd = () => {
|
||||
pointer.removeEventListener('transitionend', onTransitionEnd);
|
||||
if (tickRAF) cancelAnimationFrame(tickRAF);
|
||||
showResult();
|
||||
};
|
||||
pointer.addEventListener('transitionend', onTransitionEnd);
|
||||
|
||||
const showResult = () => {
|
||||
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('displayTargetPrice').textContent = `${Math.floor(data.received_item.price).toLocaleString()} ₽`;
|
||||
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 {
|
||||
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;
|
||||
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 = '—';
|
||||
document.getElementById('displayTargetImage').src = '/static/placeholder.png';
|
||||
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;
|
||||
refreshInventoryAjax();
|
||||
refreshHistory();
|
||||
pointer.style.transition = 'none';
|
||||
pointer.style.transform = 'rotate(0deg)';
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// Запасной таймер на случай если 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 = '🎲 Апгрейд';
|
||||
}
|
||||
}
|
||||
|
||||
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 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>
|
||||
Reference in New Issue
Block a user