Files
WFL/templates/matches.html

754 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Выбор матча</title>
<style>
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1d222b;
--border: #2b3240;
--text: #e8ecf3;
--muted: #9aa4b2;
--accent: #4f8cff;
--accent-hover: #3e78e6;
--success-bg: rgba(46, 160, 67, 0.16);
--success-border: rgba(46, 160, 67, 0.4);
--live-bg: rgba(245, 158, 11, 0.16);
--live-border: rgba(245, 158, 11, 0.4);
--scheduled-bg: transparent;
--input-bg: #11151c;
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
--radius: 14px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 28px;
font-family: Arial, sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
h1 {
margin: 0;
font-size: 28px;
font-weight: 700;
letter-spacing: 0.2px;
}
.subtitle {
color: var(--muted);
font-size: 14px;
margin-top: 6px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 18px;
align-items: end;
padding: 18px;
margin-bottom: 18px;
}
.filter-group label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 8px;
}
.check-line {
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
}
input[type="checkbox"] {
transform: scale(1.15);
accent-color: var(--accent);
}
select,
input[type="text"] {
min-height: 40px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text);
outline: none;
}
select:focus,
input[type="text"]:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.15);
}
a {
color: #8ab4ff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
thead th {
position: sticky;
top: 0;
background: var(--panel-2);
color: var(--muted);
font-size: 13px;
font-weight: 600;
text-align: left;
padding: 14px 12px;
border-bottom: 1px solid var(--border);
}
thead th:first-child {
border-top-left-radius: var(--radius);
}
thead th:last-child {
border-top-right-radius: var(--radius);
}
tbody td {
padding: 14px 12px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover {
background: rgba(255, 255, 255, 0.03);
}
tbody tr.finished {
background: var(--success-bg);
}
tbody tr.finished:hover {
background: rgba(46, 160, 67, 0.22);
}
tbody tr.live {
background: var(--live-bg);
}
tbody tr.live:hover {
background: rgba(245, 158, 11, 0.22);
}
.match-cell {
font-weight: 600;
}
.muted {
color: var(--muted);
font-size: 13px;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
border: 1px solid var(--border);
}
.status-finished {
background: var(--success-bg);
border-color: var(--success-border);
color: #8ee29b;
}
.status-live {
background: var(--live-bg);
border-color: var(--live-border);
color: #ffd27a;
}
.status-scheduled {
background: rgba(255,255,255,0.04);
color: #c8d1dc;
}
.select-form {
display: flex;
gap: 8px;
align-items: center;
}
.operator-input {
width: 170px;
}
.btn {
min-height: 40px;
padding: 0 14px;
border: none;
border-radius: 10px;
background: var(--accent);
color: white;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, transform 0.05s ease;
}
.btn:hover {
background: var(--accent-hover);
}
.btn:active {
transform: translateY(1px);
}
.empty {
padding: 28px;
color: var(--muted);
}
.id-chip {
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255,255,255,0.05);
color: var(--muted);
font-size: 12px;
}
.page-actions {
display: flex;
align-items: center;
gap: 10px;
}
.logout-form {
margin: 0;
}
.btn-secondary {
background: rgba(255,255,255,0.08);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: rgba(255,255,255,0.12);
}
.session-banner {
display: none;
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 193, 7, 0.35);
background: rgba(255, 193, 7, 0.08);
color: var(--text);
}
/* ===== MATRIX LOADER ===== */
.matrix-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.92);
}
.matrix-loader.active {
display: flex;
}
.matrix-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.matrix-content {
position: relative;
z-index: 2;
text-align: center;
padding: 24px 28px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(0, 255, 120, 0.25);
}
.matrix-title {
font-size: 26px;
color: #9dffb2;
}
.matrix-status {
font-size: 14px;
color: #d7ffe1;
}
.matrix-loader.is-error .matrix-content {
border-color: rgba(255, 90, 90, 0.35);
box-shadow: 0 0 30px rgba(255, 90, 90, 0.12);
}
.matrix-loader.is-error .matrix-title {
color: #ff9c9c;
text-shadow: 0 0 10px rgba(255, 90, 90, 0.25);
}
.matrix-loader.is-error .matrix-status {
color: #ffd4d4;
}
.matrix-loader.is-success .matrix-title {
color: #9dffb2;
}
.matrix-status {
white-space: pre-wrap;
word-break: break-word;
}
</style>
</head>
<body>
<div class="page">
<div class="page-header">
<div>
<h1>Выбор матча</h1>
<div class="subtitle">Рабочая панель оператора трансляции</div>
</div>
<div class="page-actions">
<form method="post" action="/logout" class="logout-form">
<button type="submit" class="btn btn-secondary">Выйти</button>
</form>
</div>
</div>
<div id="sessionStatusBanner" class="session-banner"></div>
<form method="get" action="/admin/matches" class="filters panel" id="filters-form">
<div class="filter-group">
<label>Фильтр по дате</label>
<div class="check-line">
<input
type="checkbox"
name="today_only"
value="true"
{% if today_only %}checked{% endif %}
onchange="document.getElementById('filters-form').submit();"
>
<span>Только сегодня</span>
</div>
</div>
<div class="filter-group">
<label for="tour">Тур</label>
<select
name="tour"
id="tour"
onchange="document.getElementById('filters-form').submit();"
>
<option value="">Все туры</option>
{% for t in tours %}
<option value="{{ t }}" {% if selected_tour == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<label>Статус матчей</label>
<div class="check-line">
<input type="hidden" name="hide_finished" value="false">
<input
type="checkbox"
name="hide_finished"
value="true"
{% if hide_finished %}checked{% endif %}
onchange="document.getElementById('filters-form').submit();"
>
<span>Завершенные матчи</span>
</div>
</div>
<div class="filter-group">
<label>&nbsp;</label>
<div class="check-line">
<a href="/admin/matches">Сбросить фильтры</a>
</div>
</div>
</form>
<div class="panel table-wrap">
{% if matches %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Дата</th>
<th>Матч</th>
<th>Тур</th>
<th>Сезон</th>
<th>Статус</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{% for row in matches %}
{% set status = (row[3] or 'scheduled') %}
<tr class="{{ status }}">
<td><span class="id-chip">{{ row[0] }}</span></td>
<td>
<div>{{ row[2] or "" }}</div>
</td>
<td class="match-cell">
{{ row[6] }} — {{ row[7] }}
</td>
<td>{{ row[4] or "" }}</td>
<td>{{ row[5] or "" }}</td>
<td>
<span class="status-badge status-{{ status }}">
{{ status }}
</span>
</td>
<td>
<form method="get" action="/admin/matches/{{ row[0] }}/select" class="select-form js-select-form">
<button type="submit" class="btn">Выбрать матч</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">Матчи не найдены.</div>
{% endif %}
</div>
</div>
<div id="matrixLoader" class="matrix-loader" aria-hidden="true">
<canvas id="matrixCanvas" class="matrix-canvas"></canvas>
<div class="matrix-content">
<div class="matrix-title">Подготовка матча</div>
<div class="matrix-status" id="matrixStatusText">Инициализация...</div>
</div>
</div>
<script>
(function () {
const loader = document.getElementById("matrixLoader");
const titleEl = document.getElementById("matrixTitle");
const statusText = document.getElementById("matrixStatusText");
const canvas = document.getElementById("matrixCanvas");
const ctx = canvas ? canvas.getContext("2d") : null;
let matrixAnimationId = null;
let drops = [];
let fontSize = 16;
let columns = 0;
let lastTime = 0;
const fps = 18;
const interval = 1000 / fps;
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@$%#*+=<>";
function resizeCanvas() {
if (!canvas || !ctx) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
columns = Math.floor(canvas.width / fontSize);
drops = Array(columns).fill(1);
}
function drawMatrix(time = 0) {
if (!canvas || !ctx) return;
if (time - lastTime < interval) {
matrixAnimationId = requestAnimationFrame(drawMatrix);
return;
}
lastTime = time;
ctx.fillStyle = "rgba(0, 0, 0, 0.08)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#00ff66";
ctx.font = `${fontSize}px monospace`;
for (let i = 0; i < drops.length; i++) {
const text = chars[Math.floor(Math.random() * chars.length)];
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) {
drops[i] = 0;
}
drops[i] ++;
}
matrixAnimationId = requestAnimationFrame(drawMatrix);
}
function startLoader(title, message) {
if (loader) {
loader.classList.add("active");
loader.classList.remove("is-error", "is-success");
}
if (titleEl) titleEl.textContent = title || "Подготовка матча";
if (statusText) statusText.textContent = message || "Инициализация...";
document.body.style.overflow = "hidden";
resizeCanvas();
if (!matrixAnimationId) {
drawMatrix();
}
}
function setLoaderState(state, title, message) {
if (loader) {
loader.classList.remove("is-error", "is-success");
if (state) loader.classList.add(state);
}
if (titleEl) titleEl.textContent = title || "";
if (statusText) statusText.textContent = message || "";
}
window.addEventListener("resize", resizeCanvas);
async function handleSelectSubmit(event) {
const form = event.target;
if (!form.classList.contains("js-select-form")) return;
event.preventDefault();
const submitButton = form.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
submitButton.textContent = "Подготовка...";
}
startLoader("Подготовка матча", "Создание игровой сессии...");
try {
const url = new URL(form.action, window.location.origin);
const response = await fetch(url.toString(), {
method: "GET",
headers: {
"X-Requested-With": "XMLHttpRequest",
"Accept": "application/json"
},
credentials: "same-origin"
});
let data = {};
try {
data = await response.json();
} catch (_) {}
if (!response.ok || !data.success) {
throw new Error(data.error || data.vmix_error || "Не удалось выбрать матч");
}
if (data.download_url) {
setLoaderState("",
"Подготовка матча",
"Формирование проекта vMix..."
);
const downloadResponse = await fetch(data.download_url, {
method: "GET",
credentials: "same-origin"
});
if (!downloadResponse.ok) {
let errText = "Не удалось скачать vMix проект";
try {
const errJson = await downloadResponse.json();
errText = errJson.error || errText;
} catch (_) {}
throw new Error(errText);
}
setLoaderState("",
"Подготовка матча",
"Скачивание проекта vMix..."
);
const blob = await downloadResponse.blob();
let filename = "project.vmix";
const disposition = downloadResponse.headers.get("Content-Disposition");
if (disposition) {
let match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (match && match[1]) {
filename = decodeURIComponent(match[1]);
} else {
match = disposition.match(/filename="([^"]+)"/i);
if (match && match[1]) {
filename = match[1];
}
}
}
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setLoaderState(
"is-success",
"Загрузка завершена",
"Проект vMix успешно подготовлен.\nПереход во вкладку «Игра»..."
);
setTimeout(() => {
window.URL.revokeObjectURL(blobUrl);
window.location.href = data.session_url + "?tab=game";
}, 1200);
return;
}
setLoaderState(
"is-success",
"Загрузка завершена",
"Матч подготовлен.\nПереход во вкладку «Игра»..."
);
setTimeout(() => {
window.location.href = data.session_url + "?tab=game";
}, 1200);
} catch (error) {
setLoaderState(
"is-error",
"Ошибка подготовки",
(error.message || "Ошибка при подготовке матча") + "\nВозврат к списку матчей через 3 секунды..."
);
setTimeout(() => {
window.location.href = "/admin/matches";
}, 3000);
} finally {
if (submitButton) {
submitButton.disabled = false;
submitButton.textContent = "Выбрать матч";
}
}
}
document.addEventListener("submit", handleSelectSubmit);
})();
(function () {
const LOGIN_URL = "/login";
const IDLE_LIMIT_MS = 2 * 60 * 60 * 1000;
const WARNING_BEFORE_MS = 60 * 1000;
const ACTIVITY_EVENTS = ["mousemove", "mousedown", "keydown", "scroll", "touchstart", "click"];
const banner = document.getElementById("sessionStatusBanner");
let lastActivityAt = Date.now();
let warned = false;
let redirected = false;
function showBanner(message) {
if (!banner) return;
banner.textContent = message;
banner.style.display = "block";
}
function markActivity() {
lastActivityAt = Date.now();
warned = false;
if (banner) banner.style.display = "none";
}
function redirectToLogin(reason) {
if (redirected) return;
redirected = true;
const url = new URL(LOGIN_URL, window.location.origin);
if (reason) url.searchParams.set("reason", reason);
window.location.replace(url.toString());
}
ACTIVITY_EVENTS.forEach((eventName) => {
window.addEventListener(eventName, markActivity, { passive: true });
});
document.addEventListener("submit", markActivity, true);
setInterval(() => {
const idleFor = Date.now() - lastActivityAt;
const leftMs = IDLE_LIMIT_MS - idleFor;
if (!warned && leftMs <= WARNING_BEFORE_MS && leftMs > 0) {
warned = true;
const leftMinutes = Math.ceil(leftMs / 60000);
showBanner(`До выхода из системы из-за неактивности осталось около ${leftMinutes} мин.`);
}
if (idleFor >= IDLE_LIMIT_MS) {
showBanner("Сессия завершена из-за 2 часов неактивности.");
redirectToLogin("idle");
}
}, 15000);
})();
</script>
</body>
</html>