1841 lines
71 KiB
HTML
1841 lines
71 KiB
HTML
<!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>ЖФЛ Управление vMix</title>
|
||
<link rel="icon" href="/static/smith.ico" type="image/x-icon">
|
||
<link rel="stylesheet" href="/static/styles.css?v={{ static_version('styles.css') }}">
|
||
|
||
|
||
<style>
|
||
.player-link-btn,
|
||
.coach-link-btn {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 0;
|
||
margin: 0;
|
||
border: none;
|
||
background: transparent;
|
||
color: inherit;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
}
|
||
|
||
.player-link-btn .last-name,
|
||
.player-link-btn .first-name,
|
||
.coach-link-btn .coach-name {
|
||
color: #8ab4ff;
|
||
text-decoration: underline;
|
||
text-underline-offset: 2px;
|
||
}
|
||
|
||
.player-link-btn:hover .last-name,
|
||
.player-link-btn:hover .first-name,
|
||
.coach-link-btn:hover .coach-name {
|
||
color: #a9c7ff;
|
||
}
|
||
|
||
.player-link-btn:focus-visible,
|
||
.coach-link-btn:focus-visible {
|
||
outline: 2px solid rgba(138, 180, 255, 0.45);
|
||
outline-offset: 2px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.vmix-download-btn {
|
||
text-decoration: none;
|
||
}
|
||
|
||
.vmix-icon {
|
||
width: 10px;
|
||
height: 10px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 4px;
|
||
background: linear-gradient(135deg, #ff5f6d 0%, #7b61ff 100%);
|
||
color: #ffffff;
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
font-family: Arial, sans-serif;
|
||
letter-spacing: 0.02em;
|
||
box-shadow: 0 0 10px rgba(123, 97, 255, 0.28);
|
||
object-fit: contain; /* чтобы не растягивалась */
|
||
display: block;
|
||
|
||
}
|
||
.vmix-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border-radius: 10px;
|
||
background: #151515;
|
||
color: #fff;
|
||
text-decoration: none;
|
||
border: 1px solid rgba(255,255,255,0.12);
|
||
transition: 0.2s ease;
|
||
}
|
||
|
||
.vmix-btn:hover {
|
||
background: #222;
|
||
}
|
||
|
||
|
||
</style>
|
||
|
||
</head>
|
||
|
||
<body>
|
||
<div class="page">
|
||
<div class="topbar">
|
||
<div class="topbar-inner">
|
||
<div class="match-header">
|
||
<div class="team-box">
|
||
{% if session[15] %}
|
||
<img
|
||
src="{{ session[15] }}"
|
||
alt="{{ session[14] }}"
|
||
class="team-logo"
|
||
/>
|
||
{% endif %}
|
||
<div>
|
||
<div class="team-name">{{ session[14] }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="match-meta">
|
||
<div class="match-tour">{{ session[10] or "Тур не указан" }}</div>
|
||
<div class="match-time">
|
||
{{ session[9] or "Время не указано" }}
|
||
</div>
|
||
<div class="match-place">
|
||
{{ session[12] or "Стадион не указан" }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="team-box right">
|
||
<div>
|
||
<div class="team-name">{{ session[18] }}</div>
|
||
</div>
|
||
{% if session[19] %}
|
||
<img
|
||
src="{{ session[19] }}"
|
||
alt="{{ session[18] }}"
|
||
class="team-logo"
|
||
/>
|
||
{% endif %}
|
||
</div>
|
||
<div class="header-actions compact">
|
||
<form
|
||
method="post"
|
||
action="/admin/session/{{ session[3] }}/load-match-data"
|
||
id="loadMatchDataForm"
|
||
>
|
||
<button
|
||
type="submit"
|
||
class="icon-btn header-icon"
|
||
title="Загрузить данные"
|
||
>
|
||
🌐
|
||
</button>
|
||
</form>
|
||
|
||
<a href="/admin/session/{{ session[3] }}/download-vmix-page" class="vmix-btn">
|
||
<img src="/static/images/vmix_icon.png" class="vmix-icon" alt="vMix">
|
||
<a
|
||
href="/info"
|
||
target="_blank"
|
||
class="icon-btn header-icon"
|
||
title="Информация"
|
||
style="text-decoration: none;"
|
||
>
|
||
?
|
||
</a>
|
||
</a>
|
||
<form
|
||
method="post"
|
||
action="/admin/session/{{ session[3] }}/close"
|
||
>
|
||
<button
|
||
type="submit"
|
||
class="icon-btn header-icon"
|
||
title="Закрыть сессию"
|
||
>
|
||
⏹
|
||
</button>
|
||
</form>
|
||
|
||
<button
|
||
type="button"
|
||
id="editModeToggle"
|
||
class="icon-btn header-icon"
|
||
title="Режим редактирования"
|
||
onclick="toggleEditMode()"
|
||
>
|
||
✏️
|
||
</button>
|
||
{% if current_user and current_user.get("role") == "admin" %}
|
||
<a
|
||
href="/admin/db"
|
||
class="icon-btn header-icon"
|
||
title="База данных"
|
||
style="text-decoration: none; display: inline-flex; align-items: center; justify-content: center;"
|
||
>
|
||
🗄️
|
||
</a>
|
||
{% endif %}
|
||
|
||
<form method="post" action="/logout">
|
||
<button
|
||
type="submit"
|
||
class="icon-btn header-icon danger"
|
||
title="Выйти"
|
||
>
|
||
❌
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<div
|
||
id="sessionStatusBanner"
|
||
class="empty-box"
|
||
style="
|
||
display: none;
|
||
margin-bottom: 16px;
|
||
border: 1px solid rgba(255, 193, 7, 0.35);
|
||
background: rgba(255, 193, 7, 0.08);
|
||
"
|
||
>
|
||
Сессия истекла. Выполняется переход на страницу входа…
|
||
</div>
|
||
<div class="tabs">
|
||
<a
|
||
class="tab-link {% if tab == 'game' %}active{% endif %}"
|
||
href="/admin/session/{{ session[3] }}?tab=game"
|
||
>Игра</a
|
||
>
|
||
<a
|
||
class="tab-link {% if tab == 'formations' %}active{% endif %}"
|
||
href="/admin/session/{{ session[3] }}?tab=formations"
|
||
>Расстановки команд</a
|
||
>
|
||
<a
|
||
class="tab-link {% if tab == 'referees' %}active{% endif %}"
|
||
href="/admin/session/{{ session[3] }}?tab=referees"
|
||
data-vmix-title="СУДЕЙСКАЯ БРИГАДА"
|
||
>Судьи</a
|
||
>
|
||
<a
|
||
class="tab-link {% if tab == 'tour_schedule' %}active{% endif %}"
|
||
href="/admin/session/{{ session[3] }}?tab=tour_schedule"
|
||
data-vmix-title="РАСПИСАНИЕ ТУРА"
|
||
>
|
||
Расписание тура
|
||
</a>
|
||
|
||
{% if is_russian_cup %}
|
||
<a
|
||
class="tab-link {% if tab == 'penalties' %}active{% endif %}"
|
||
href="/admin/session/{{ session[3] }}?tab=penalties"
|
||
data-vmix-title="ПЕНАЛЬТИ"
|
||
>
|
||
Пенальти
|
||
</a>
|
||
{% else %}
|
||
<a
|
||
class="tab-link {% if tab == 'standings' %}active{% endif %}"
|
||
href="/admin/session/{{ session[3] }}?tab=standings"
|
||
data-vmix-title="ТУРНИРНАЯ ТАБЛИЦА"
|
||
>
|
||
Турнирная таблица
|
||
</a>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% if tab == 'game' %}
|
||
<div class="game-layout">
|
||
<div class="game-main">
|
||
<div class="panel">
|
||
<div class="match-toolbar">
|
||
<div class="match-toolbar-left">
|
||
<button
|
||
type="button"
|
||
class="btn btn-primary"
|
||
data-period-btn="1H"
|
||
onclick="setMatchPeriod('1H')"
|
||
>
|
||
1-й тайм
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary"
|
||
data-period-btn="HT"
|
||
onclick="setMatchPeriod('HT')"
|
||
>
|
||
Перерыв
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn btn-primary"
|
||
data-period-btn="2H"
|
||
onclick="setMatchPeriod('2H')"
|
||
>
|
||
2-й тайм
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary"
|
||
data-period-btn="FT"
|
||
onclick="setMatchPeriod('FT')"
|
||
>
|
||
Завершить
|
||
</button>
|
||
</div>
|
||
|
||
<div class="match-toolbar-right">
|
||
<span class="period-badge" id="periodBadge"
|
||
>Матч не начат</span
|
||
>
|
||
<div class="timer-box timer-box-clickable" id="matchTimer" onclick="openClockEditor()" title="Нажмите, чтобы изменить время">00:00</div>
|
||
<div class="score-box" id="matchScore">0 : 0</div>
|
||
|
||
<div class="extra-time-wrap">
|
||
<div class="extra-time-control">
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
class="extra-time-input"
|
||
id="extraTimeMinutes"
|
||
placeholder="+мин"
|
||
/>
|
||
<div class="extra-time-arrows">
|
||
<button type="button" onclick="stepExtraTime(1)">
|
||
▲
|
||
</button>
|
||
<button type="button" onclick="stepExtraTime(-1)">
|
||
▼
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary"
|
||
onclick="applyExtraTime()"
|
||
>
|
||
Доп. время
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lineups-grid">
|
||
<div class="panel">
|
||
{% if coaches.home %}
|
||
<div class="coaches-block" id="homeCoachesBlock">
|
||
{% for c in coaches.home %}
|
||
<div class="coach-line">
|
||
<button
|
||
type="button"
|
||
class="coach-left vmix-person-trigger player-link-btn coach-link-btn"
|
||
data-vmix-person-type="coach"
|
||
data-side="home"
|
||
data-name="{{ c.coach_name }}"
|
||
data-role="{{ c.role or '' }}"
|
||
title="Показать титр тренера"
|
||
>
|
||
<span class="coach-name">{{ c.coach_name }}</span>
|
||
{% if c.role %}
|
||
<span class="coach-badge">{{ c.role }}</span>
|
||
{% endif %}
|
||
</button>
|
||
<div class="coach-actions">
|
||
<button
|
||
type="button"
|
||
class="icon-btn yellow-card"
|
||
title="Жёлтая карточка"
|
||
data-coach-side="home"
|
||
data-coach-name="{{ c.coach_name }}"
|
||
data-card-type="yellow"
|
||
onclick="addCoachEvent('home', 'yellow', this.dataset.coachName, this)"
|
||
></button>
|
||
|
||
<button
|
||
type="button"
|
||
class="icon-btn red-card"
|
||
title="Красная карточка"
|
||
data-coach-side="home"
|
||
data-coach-name="{{ c.coach_name }}"
|
||
data-card-type="red"
|
||
onclick="addCoachEvent('home', 'red', this.dataset.coachName, this)"
|
||
></button>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="section-title">Основной состав</div>
|
||
{% if lineups.home_starting %}
|
||
<table class="game-lineup-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="num-col">#</th>
|
||
<th class="player-col">Игрок</th>
|
||
<th class="actions-col">Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for p in lineups.home_starting %}
|
||
<tr
|
||
class="lineup-row"
|
||
data-side="home"
|
||
data-role="starting"
|
||
data-player-id="{{ p.player_id | default(p.number) }}"
|
||
data-player-name="{{ p.player_name or '' }}"
|
||
data-player-last="{{ p.last_name or p.player_name or '' }}"
|
||
data-player-first="{{ p.first_name or '' }}"
|
||
data-player-number="{{ p.number or '' }}"
|
||
data-player-position="{{ p.position or '' }}"
|
||
data-player-captain="{{ 1 if p.is_captain else 0 }}"
|
||
data-player-photo-enabled="{{ 1 if p.photo_enabled else 0 }}"
|
||
>
|
||
<td class="num-cell">
|
||
<span class="num-with-photo-status">
|
||
{% if not p.photo_enabled %}
|
||
<span class="no-photo-icon" title="Фото выключено: в JSON будет подставлена EMPTY.png" aria-label="Фото выключено">📷</span>
|
||
{% endif %}
|
||
<span class="player-number-value">{{ p.number }}</span>
|
||
</span>
|
||
</td>
|
||
<td class="player-cell" title="Показать титр игрока">
|
||
<div
|
||
class="player-inline vmix-person-trigger player-link"
|
||
data-vmix-person-type="player"
|
||
data-side="home"
|
||
data-name="{{ (((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '') }}"
|
||
data-first-name="{{ p.first_name or '' }}"
|
||
data-last-name="{{ p.last_name or '' }}"
|
||
data-role="{{ p.position or '' }}"
|
||
>
|
||
<div class="player-inline-text">
|
||
<span class="last-name">{{ p.last_name or p.player_name }}</span>
|
||
{% if p.first_name %}
|
||
<span class="first-name">{{ p.first_name }}</span>
|
||
{% endif %}
|
||
</div>
|
||
<span class="badges">
|
||
{% if p.position and p.position|lower in ['вр.', 'вр', 'вратарь', 'gk', 'goalkeeper'] %}
|
||
<span class="gk-badge">ВР</span>
|
||
{% endif %}
|
||
{% if p.is_captain %}
|
||
<span class="captain-badge">К</span>
|
||
{% endif %}
|
||
</span>
|
||
</div>
|
||
</td> <td class="actions-col">
|
||
<div class="row-actions">
|
||
<button
|
||
type="button"
|
||
class="icon-btn goal"
|
||
title="Гол"
|
||
onclick="addPlayerEvent('home', 'goal', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
|
||
>
|
||
⚽
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="icon-btn"
|
||
title="Автогол"
|
||
onclick="addPlayerEvent('home', 'own_goal', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
|
||
>
|
||
АГ
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="icon-btn"
|
||
title="Пенальти"
|
||
onclick="addPlayerEvent('home', 'penalty', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
|
||
>
|
||
П
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="icon-btn sub"
|
||
title="Замена"
|
||
onclick="beginSubstitution('home', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }}, this)"
|
||
>
|
||
⇄
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="icon-btn yellow-card"
|
||
title="Жёлтая карточка"
|
||
onclick="addPlayerEvent('home', 'yellow', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
|
||
></button>
|
||
<button
|
||
type="button"
|
||
class="icon-btn red-card"
|
||
title="Красная карточка"
|
||
onclick="addPlayerEvent('home', 'red', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
|
||
></button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% else %}
|
||
<div class="empty-box">Основной состав пока не загружен.</div>
|
||
{% endif %}
|
||
|
||
<div class="section-title">Запасные</div>
|
||
<div class="bench-pick-hint" id="homeBenchHint">
|
||
Выбери игрока из запасных для завершения замены
|
||
</div>
|
||
{% if lineups.home_bench %}
|
||
<table class="game-lineup-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="num-col">#</th>
|
||
<th class="player-col">Игрок</th>
|
||
<th class="actions-col">Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for p in lineups.home_bench %}
|
||
<tr
|
||
class="lineup-row"
|
||
data-side="home"
|
||
data-role="bench"
|
||
data-player-id="{{ p.player_id | default(p.number) }}"
|
||
data-player-name="{{ p.player_name or '' }}"
|
||
data-player-last="{{ p.last_name or p.player_name or '' }}"
|
||
data-player-first="{{ p.first_name or '' }}"
|
||
data-player-number="{{ p.number or '' }}"
|
||
data-player-position="{{ p.position or '' }}"
|
||
data-player-captain="{{ 1 if p.is_captain else 0 }}"
|
||
data-player-photo-enabled="{{ 1 if p.photo_enabled else 0 }}"
|
||
>
|
||
<td class="num-cell">
|
||
<span class="num-with-photo-status">
|
||
{% if not p.photo_enabled %}
|
||
<span class="no-photo-icon" title="Фото выключено: в JSON будет подставлена EMPTY.png" aria-label="Фото выключено">📷</span>
|
||
{% endif %}
|
||
<span class="player-number-value">{{ p.number }}</span>
|
||
</span>
|
||
</td>
|
||
<td class="player-cell" title="Показать титр игрока">
|
||
<div
|
||
class="player-inline vmix-person-trigger player-link"
|
||
data-vmix-person-type="player"
|
||
data-side="home"
|
||
data-name="{{ (((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '') }}"
|
||
data-first-name="{{ p.first_name or '' }}"
|
||
data-last-name="{{ p.last_name or '' }}"
|
||
data-role="{{ p.position or '' }}"
|
||
>
|
||
<div class="player-inline-text">
|
||
<span class="last-name">{{ p.last_name or p.player_name }}</span>
|
||
{% if p.first_name %}
|
||
<span class="first-name">{{ p.first_name }}</span>
|
||
{% endif %}
|
||
</div>
|
||
<span class="badges">
|
||
{% if p.position and p.position|lower in ['вр.', 'вр', 'вратарь', 'gk', 'goalkeeper'] %}
|
||
<span class="gk-badge">ВР</span>
|
||
{% endif %}
|
||
{% if p.is_captain %}
|
||
<span class="captain-badge">К</span>
|
||
{% endif %}
|
||
</span>
|
||
</div>
|
||
</td> <td class="actions-col">
|
||
<div class="row-actions">
|
||
<button
|
||
type="button"
|
||
class="icon-btn sub"
|
||
title="Замена"
|
||
onclick="selectBenchPlayer('home', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }}, this)"
|
||
>
|
||
⇄
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% else %}
|
||
<div class="empty-box">Запасные пока не загружены.</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div class="panel">
|
||
{% if coaches.away %}
|
||
<div
|
||
class="coaches-block coaches-block-right"
|
||
id="awayCoachesBlock"
|
||
>
|
||
{% for c in coaches.away %}
|
||
<div class="coach-line">
|
||
<button
|
||
type="button"
|
||
class="coach-left vmix-person-trigger player-link-btn coach-link-btn"
|
||
data-vmix-person-type="coach"
|
||
data-side="away"
|
||
data-name="{{ c.coach_name }}"
|
||
data-role="{{ c.role or '' }}"
|
||
title="Показать титр тренера"
|
||
>
|
||
<span class="coach-name">{{ c.coach_name }}</span>
|
||
{% if c.role %}
|
||
<span class="coach-badge">{{ c.role }}</span>
|
||
{% endif %}
|
||
</button>
|
||
<div class="coach-actions">
|
||
<button
|
||
type="button"
|
||
class="icon-btn yellow-card"
|
||
title="Жёлтая карточка"
|
||
data-coach-side="away"
|
||
data-coach-name="{{ c.coach_name }}"
|
||
data-card-type="yellow"
|
||
onclick="addCoachEvent('away', 'yellow', this.dataset.coachName, this)"
|
||
></button>
|
||
|
||
<button
|
||
type="button"
|
||
class="icon-btn red-card"
|
||
title="Красная карточка"
|
||
data-coach-side="away"
|
||
data-coach-name="{{ c.coach_name }}"
|
||
data-card-type="red"
|
||
onclick="addCoachEvent('away', 'red', this.dataset.coachName, this)"
|
||
></button>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="section-title">Основной состав</div>
|
||
{% if lineups.away_starting %}
|
||
<table class="game-lineup-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="num-col">#</th>
|
||
<th class="player-col">Игрок</th>
|
||
<th class="actions-col">Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for p in lineups.away_starting %}
|
||
<tr
|
||
class="lineup-row"
|
||
data-side="away"
|
||
data-role="starting"
|
||
data-player-id="{{ p.player_id | default(p.number) }}"
|
||
data-player-name="{{ p.player_name or '' }}"
|
||
data-player-last="{{ p.last_name or p.player_name or '' }}"
|
||
data-player-first="{{ p.first_name or '' }}"
|
||
data-player-number="{{ p.number or '' }}"
|
||
data-player-position="{{ p.position or '' }}"
|
||
data-player-captain="{{ 1 if p.is_captain else 0 }}"
|
||
data-player-photo-enabled="{{ 1 if p.photo_enabled else 0 }}"
|
||
>
|
||
<td class="num-cell">
|
||
<span class="num-with-photo-status">
|
||
{% if not p.photo_enabled %}
|
||
<span class="no-photo-icon" title="Фото выключено: в JSON будет подставлена EMPTY.png" aria-label="Фото выключено">📷</span>
|
||
{% endif %}
|
||
<span class="player-number-value">{{ p.number }}</span>
|
||
</span>
|
||
</td>
|
||
<td class="player-cell" title="Показать титр игрока">
|
||
<div
|
||
class="player-inline vmix-person-trigger player-link"
|
||
data-vmix-person-type="player"
|
||
data-side="away"
|
||
data-name="{{ (((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '') }}"
|
||
data-first-name="{{ p.first_name or '' }}"
|
||
data-last-name="{{ p.last_name or '' }}"
|
||
data-role="{{ p.position or '' }}"
|
||
>
|
||
<div class="player-inline-text">
|
||
<span class="last-name">{{ p.last_name or p.player_name }}</span>
|
||
{% if p.first_name %}
|
||
<span class="first-name">{{ p.first_name }}</span>
|
||
{% endif %}
|
||
</div>
|
||
<span class="badges">
|
||
{% if p.position and p.position|lower in ['вр.', 'вр', 'вратарь', 'gk', 'goalkeeper'] %}
|
||
<span class="gk-badge">ВР</span>
|
||
{% endif %}
|
||
{% if p.is_captain %}
|
||
<span class="captain-badge">К</span>
|
||
{% endif %}
|
||
</span>
|
||
</div>
|
||
</td> <td class="actions-col">
|
||
<div class="row-actions">
|
||
<button
|
||
type="button"
|
||
class="icon-btn goal"
|
||
title="Гол"
|
||
onclick="addPlayerEvent('away', 'goal', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
|
||
>
|
||
⚽
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="icon-btn"
|
||
title="Автогол"
|
||
onclick="addPlayerEvent('away', 'own_goal', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
|
||
>
|
||
АГ
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="icon-btn"
|
||
title="Пенальти"
|
||
onclick="addPlayerEvent('away', 'penalty', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
|
||
>
|
||
П
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="icon-btn sub"
|
||
title="Замена"
|
||
onclick="beginSubstitution('away', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }}, this)"
|
||
>
|
||
⇄
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="icon-btn yellow-card"
|
||
title="Жёлтая карточка"
|
||
onclick="addPlayerEvent('away', 'yellow', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
|
||
></button>
|
||
<button
|
||
type="button"
|
||
class="icon-btn red-card"
|
||
title="Красная карточка"
|
||
onclick="addPlayerEvent('away', 'red', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
|
||
></button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% else %}
|
||
<div class="empty-box">Основной состав пока не загружен.</div>
|
||
{% endif %}
|
||
|
||
<div class="section-title">Запасные</div>
|
||
<div class="bench-pick-hint" id="awayBenchHint">
|
||
Выбери игрока из запасных для завершения замены
|
||
</div>
|
||
{% if lineups.away_bench %}
|
||
<table class="game-lineup-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="num-col">#</th>
|
||
<th class="player-col">Игрок</th>
|
||
<th class="actions-col">Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for p in lineups.away_bench %}
|
||
<tr
|
||
class="lineup-row"
|
||
data-side="away"
|
||
data-role="bench"
|
||
data-player-id="{{ p.player_id | default(p.number) }}"
|
||
data-player-name="{{ p.player_name or '' }}"
|
||
data-player-last="{{ p.last_name or p.player_name or '' }}"
|
||
data-player-first="{{ p.first_name or '' }}"
|
||
data-player-number="{{ p.number or '' }}"
|
||
data-player-position="{{ p.position or '' }}"
|
||
data-player-captain="{{ 1 if p.is_captain else 0 }}"
|
||
data-player-photo-enabled="{{ 1 if p.photo_enabled else 0 }}"
|
||
>
|
||
<td class="num-cell">
|
||
<span class="num-with-photo-status">
|
||
{% if not p.photo_enabled %}
|
||
<span class="no-photo-icon" title="Фото выключено: в JSON будет подставлена EMPTY.png" aria-label="Фото выключено">📷</span>
|
||
{% endif %}
|
||
<span class="player-number-value">{{ p.number }}</span>
|
||
</span>
|
||
</td>
|
||
<td class="player-cell" title="Показать титр игрока">
|
||
<div
|
||
class="player-inline vmix-person-trigger player-link"
|
||
data-vmix-person-type="player"
|
||
data-side="away"
|
||
data-name="{{ (((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '') }}"
|
||
data-first-name="{{ p.first_name or '' }}"
|
||
data-last-name="{{ p.last_name or '' }}"
|
||
data-role="{{ p.position or '' }}"
|
||
>
|
||
<div class="player-inline-text">
|
||
<span class="last-name">{{ p.last_name or p.player_name }}</span>
|
||
{% if p.first_name %}
|
||
<span class="first-name">{{ p.first_name }}</span>
|
||
{% endif %}
|
||
</div>
|
||
<span class="badges">
|
||
{% if p.position and p.position|lower in ['вр.', 'вр', 'вратарь', 'gk', 'goalkeeper'] %}
|
||
<span class="gk-badge">ВР</span>
|
||
{% endif %}
|
||
{% if p.is_captain %}
|
||
<span class="captain-badge">К</span>
|
||
{% endif %}
|
||
</span>
|
||
</div>
|
||
</td> <td class="actions-col">
|
||
<div class="row-actions">
|
||
<button
|
||
type="button"
|
||
class="icon-btn sub"
|
||
title="Замена"
|
||
onclick="selectBenchPlayer('away', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }}, this)"
|
||
>
|
||
⇄
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% else %}
|
||
<div class="empty-box">Запасные пока не загружены.</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel events-panel">
|
||
<div class="panel-title-row">
|
||
<div>
|
||
<div class="panel-title">События матча</div>
|
||
<div class="small-meta">
|
||
Слева хозяева, справа гости. Время идёт сверху вниз по общей
|
||
шкале.
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary"
|
||
onclick="openClearMatchModal()"
|
||
>
|
||
Очистить
|
||
</button>
|
||
</div>
|
||
|
||
<div class="events-list" id="eventsList">
|
||
<div class="empty-box">Событий пока нет.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% elif tab == 'formations' %}
|
||
<div class="lineups-grid">
|
||
<div class="panel">
|
||
<div class="team-panel-title">Расстановка — {{ session[14] }}</div>
|
||
|
||
<div class="formation-toolbar">
|
||
<select id="home-preset" class="formation-select" data-edit-lock onchange="saveSelectedPreset('home')">
|
||
<option value="4-4-2">4-4-2</option>
|
||
<option value="4-3-3">4-3-3</option>
|
||
<option value="4-2-3-1">4-2-3-1</option>
|
||
<option value="3-5-2">3-5-2</option>
|
||
<option value="5-3-2">5-3-2</option>
|
||
<option value="4-1-4-1">4-1-4-1</option>
|
||
<option value="3-4-3">3-4-3</option>
|
||
<option value="4-5-1">4-5-1</option>
|
||
<option value="5-4-1">5-4-1</option>
|
||
<option value="4-3-1-2">4-3-1-2</option>
|
||
<option value="3-6-1">3-6-1</option>
|
||
</select>
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary"
|
||
data-edit-lock
|
||
onclick="applyPreset('home')"
|
||
>
|
||
Применить расстановку
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn btn-primary"
|
||
data-edit-lock
|
||
onclick="saveFormation('home')"
|
||
>
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
|
||
<div class="pitch-half" id="pitch-home">
|
||
<div class="pitch-center-line"></div>
|
||
<div class="pitch-center-circle"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="team-panel-title">Расстановка — {{ session[18] }}</div>
|
||
|
||
<div class="formation-toolbar">
|
||
<select id="away-preset" class="formation-select" data-edit-lock onchange="saveSelectedPreset('away')">
|
||
<option value="4-4-2">4-4-2</option>
|
||
<option value="4-3-3">4-3-3</option>
|
||
<option value="4-2-3-1">4-2-3-1</option>
|
||
<option value="3-5-2">3-5-2</option>
|
||
<option value="5-3-2">5-3-2</option>
|
||
<option value="4-1-4-1">4-1-4-1</option>
|
||
<option value="3-4-3">3-4-3</option>
|
||
<option value="4-5-1">4-5-1</option>
|
||
<option value="5-4-1">5-4-1</option>
|
||
<option value="4-3-1-2">4-3-1-2</option>
|
||
<option value="3-6-1">3-6-1</option>
|
||
</select>
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary"
|
||
data-edit-lock
|
||
onclick="applyPreset('away')"
|
||
>
|
||
Применить расстановку
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn btn-primary"
|
||
data-edit-lock
|
||
onclick="saveFormation('away')"
|
||
>
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
|
||
<div class="pitch-half" id="pitch-away">
|
||
<div class="pitch-center-line"></div>
|
||
<div class="pitch-center-circle"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
{% elif tab == 'referees' %}
|
||
<div class="panel">
|
||
<div class="team-panel-title">Судейская бригада</div>
|
||
|
||
<!-- сюда JS рендерит таблицу -->
|
||
<div id="refereesEditorWrap"></div>
|
||
|
||
<!-- кнопки редактирования -->
|
||
<div class="referee-edit-controls" data-edit-lock-wrap>
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary"
|
||
onclick="addRefereeFromSelect()"
|
||
>
|
||
Добавить судью
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
class="btn btn-primary"
|
||
onclick="saveReferees()"
|
||
>
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{% elif tab == 'tour_schedule' %}
|
||
<div class="panel">
|
||
<div class="team-panel-title">Расписание тура</div>
|
||
|
||
{% set month_names = { 1: 'января', 2: 'февраля', 3: 'марта', 4:
|
||
'апреля', 5: 'мая', 6: 'июня', 7: 'июля', 8: 'августа', 9: 'сентября',
|
||
10: 'октября', 11: 'ноября', 12: 'декабря' } %} {% if tour_schedule %}
|
||
<table class="table-compact tour-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="tour-date-col">Дата</th>
|
||
<th class="tour-match-col">Матч</th>
|
||
<th class="tour-meta-col">Счёт / статус</th>
|
||
<th class="tour-stadium-col">Стадион</th>
|
||
<th class="tour-channel-col">Канал</th>
|
||
<th class="tour-actions-col tour-edit-only">Правка</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for m in tour_schedule %}
|
||
{% set row_key = m.match_external_id %}
|
||
{% set home_score_value = '' if m.home_score is none else m.home_score %}
|
||
{% set away_score_value = '' if m.away_score is none else m.away_score %}
|
||
<tr class="tour-schedule-row" data-schedule-row="{{ row_key }}">
|
||
<td class="tour-date-cell">
|
||
{% if m.match_date %}
|
||
{{ m.match_date.day }} {{ month_names[m.match_date.month] }}
|
||
{{ m.match_date.year }}
|
||
{% if m.match_date.hour != 0 or m.match_date.minute != 0 %}
|
||
{{ "%02d:%02d"|format(m.match_date.hour, m.match_date.minute) }}
|
||
{% endif %} {% else %} — {% endif %}
|
||
</td>
|
||
|
||
<td class="tour-match-cell">
|
||
{{ m.home_team_name }} — {{ m.away_team_name }}
|
||
{% if m.match_external_id == session[8] %}
|
||
<span class="gk-badge">ТЕКУЩИЙ</span>
|
||
{% endif %}
|
||
</td>
|
||
|
||
<td class="tour-meta-cell">
|
||
<div class="tour-meta-wrap tour-meta-display" data-schedule-display="{{ row_key }}">
|
||
{% if m.home_score is not none and m.away_score is not none %}
|
||
<span class="score-badge">{{ m.home_score }} : {{ m.away_score }}</span>
|
||
{% endif %}
|
||
{% if m.status == 'finished' %}
|
||
<span class="status-badge status-finished">Завершён</span>
|
||
{% elif m.status == 'live' %}
|
||
<span class="status-badge status-live">Live</span>
|
||
{% elif m.status == 'scheduled' %}
|
||
<span class="status-badge status-scheduled">Не начался</span>
|
||
{% else %}
|
||
<span class="status-badge status-scheduled">{{ m.status or "—" }}</span>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div class="tour-score-edit" data-schedule-edit="{{ row_key }}" hidden>
|
||
<div class="tour-edit-line">
|
||
<div class="tour-score-inputs" aria-label="Счёт">
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
class="tour-score-input"
|
||
data-match-id="{{ row_key }}"
|
||
data-score-side="home"
|
||
value="{{ home_score_value }}"
|
||
data-initial-value="{{ home_score_value }}"
|
||
placeholder="—"
|
||
disabled
|
||
>
|
||
<span class="tour-score-separator">:</span>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
class="tour-score-input"
|
||
data-match-id="{{ row_key }}"
|
||
data-score-side="away"
|
||
value="{{ away_score_value }}"
|
||
data-initial-value="{{ away_score_value }}"
|
||
placeholder="—"
|
||
disabled
|
||
>
|
||
</div>
|
||
|
||
<select
|
||
class="tour-status-select"
|
||
data-match-id="{{ row_key }}"
|
||
data-initial-status="{{ m.status or 'scheduled' }}"
|
||
disabled
|
||
>
|
||
<option value="scheduled" {% if m.status == 'scheduled' or not m.status %}selected{% endif %}>Не начался</option>
|
||
<option value="live" {% if m.status == 'live' %}selected{% endif %}>Live</option>
|
||
<option value="finished" {% if m.status == 'finished' %}selected{% endif %}>Завершён</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
|
||
<td class="stadium-cell">
|
||
{{ m.stadium_name or "—" }}
|
||
</td>
|
||
|
||
<td class="tour-channel-cell">
|
||
{% set channel = m.channel or "" %}
|
||
|
||
<label class="channel-check">
|
||
<input
|
||
type="checkbox"
|
||
class="tour-channel-checkbox"
|
||
data-channel="match"
|
||
data-match-id="{{ m.match_external_id }}"
|
||
onchange="saveTourChannel('{{ m.match_external_id }}')"
|
||
{% if channel in ['match', 'match_and_premier'] %}checked{% endif %}
|
||
>
|
||
Матч
|
||
</label>
|
||
|
||
<label class="channel-check">
|
||
<input
|
||
type="checkbox"
|
||
class="tour-channel-checkbox"
|
||
data-channel="match_premier"
|
||
data-match-id="{{ m.match_external_id }}"
|
||
onchange="saveTourChannel('{{ m.match_external_id }}')"
|
||
{% if channel in ['match_premier', 'match_and_premier'] %}checked{% endif %}
|
||
>
|
||
Матч Премьер
|
||
</label>
|
||
</td>
|
||
|
||
<td class="tour-actions-cell tour-edit-only">
|
||
<div class="tour-save-actions" data-schedule-save-actions="{{ row_key }}" hidden>
|
||
<button
|
||
type="button"
|
||
class="btn btn-primary tour-save-btn"
|
||
onclick="saveTourScheduleMatch('{{ row_key }}')"
|
||
title="Сохранить счёт и статус"
|
||
>✓</button>
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary tour-cancel-btn"
|
||
onclick="cancelTourScheduleEdit('{{ row_key }}')"
|
||
title="Отмена"
|
||
>×</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% else %}
|
||
<div class="empty-box">Расписание тура не найдено.</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% elif tab == 'standings' %}
|
||
<div class="panel">
|
||
<div class="team-panel-title">Турнирная таблица</div>
|
||
|
||
{% if standings %}
|
||
<table class="standings-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="st-pos-col">#</th>
|
||
<th class="st-logo-col"></th>
|
||
<th class="st-team-col">Команда</th>
|
||
<th class="st-stat-col">И</th>
|
||
<th class="st-stat-col">В</th>
|
||
<th class="st-stat-col">Н</th>
|
||
<th class="st-stat-col">П</th>
|
||
<th class="st-gfga-col">М</th>
|
||
<th class="st-points-col">О</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for s in standings %}
|
||
<tr
|
||
class="{% if s.team_name == session[14] %}standings-row-home{% elif s.team_name == session[18] %}standings-row-away{% endif %}"
|
||
>
|
||
<td class="num-cell">{{ s.position }}</td>
|
||
<td class="st-logo-col">
|
||
{% if s.team_logo %}
|
||
<img
|
||
src="{{ s.team_logo }}"
|
||
alt="{{ s.team_name }}"
|
||
class="standings-logo"
|
||
/>
|
||
{% endif %}
|
||
</td>
|
||
<td class="player-name">{{ s.team_name }}</td>
|
||
<td class="num-cell">{{ s.played }}</td>
|
||
<td class="num-cell">{{ s.wins }}</td>
|
||
<td class="num-cell">{{ s.draws }}</td>
|
||
<td class="num-cell">{{ s.losses }}</td>
|
||
<td class="num-cell">
|
||
{{ s.goals_for }}-{{ s.goals_against }}
|
||
</td>
|
||
<td class="num-cell">{{ s.points }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% else %}
|
||
<div class="empty-box">Турнирная таблица пока не загружена.</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% elif tab == 'penalties' and is_russian_cup %}
|
||
<div class="panel penalty-panel" id="penaltyPanel">
|
||
<div class="penalty-header">
|
||
<div>
|
||
<div class="team-panel-title">Серия пенальти</div>
|
||
<div class="penalty-subtitle">Быстро отмечай удары для Кубка России. В vMix всегда уходят 5 строк: 1–5, затем 6–10 снова в строки 1–5.</div>
|
||
</div>
|
||
<div class="penalty-header-actions">
|
||
<button type="button" class="btn btn-secondary" onclick="addPenaltyRound()">+ Добавить серию</button>
|
||
<button type="button" class="btn btn-secondary" onclick="deleteLastPenaltyRound()">Удалить последнюю серию</button>
|
||
<button type="button" class="btn btn-danger" onclick="clearPenaltyShootout()">Очистить</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="penalty-scoreboard">
|
||
<div class="penalty-team-card home">
|
||
<div class="penalty-team-name">{{ session[14] }}</div>
|
||
<div class="penalty-team-score" id="penaltyHomeScore">0</div>
|
||
<div class="penalty-next-label" id="penaltyHomeNext">Следующий удар: 1</div>
|
||
<div class="penalty-fast-actions">
|
||
<button type="button" class="penalty-fast-btn scored" onclick="setNextPenaltyShot('home', 'scored')">Забил</button>
|
||
<button type="button" class="penalty-fast-btn missed" onclick="setNextPenaltyShot('home', 'missed')">Не забил</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="penalty-team-card away">
|
||
<div class="penalty-team-name">{{ session[18] }}</div>
|
||
<div class="penalty-team-score" id="penaltyAwayScore">0</div>
|
||
<div class="penalty-next-label" id="penaltyAwayNext">Следующий удар: 1</div>
|
||
<div class="penalty-fast-actions">
|
||
<button type="button" class="penalty-fast-btn scored" onclick="setNextPenaltyShot('away', 'scored')">Забил</button>
|
||
<button type="button" class="penalty-fast-btn missed" onclick="setNextPenaltyShot('away', 'missed')">Не забил</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="penalty-table-wrap">
|
||
<table class="penalty-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Серия</th>
|
||
<th>{{ session[14] }}</th>
|
||
<th>{{ session[18] }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="penaltyRoundsBody">
|
||
<tr>
|
||
<td colspan="3" class="empty-box">Загрузка серии пенальти…</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="penalty-footer">
|
||
<div class="penalty-help">Любой удар можно исправить прямо в таблице: нажми “Забил”, “Не забил” или “Очистить” в нужной ячейке. После 5 ударов продолжай 6-й, 7-й и дальше — для vMix они будут записываться в первые 5 строк нового блока.</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% else %}
|
||
<div class="panel">
|
||
<div class="empty-box">Эта вкладка пока в разработке.</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
window.MATCH_DATA = {
|
||
teamNames: {
|
||
home: {{ session[14] | tojson }},
|
||
away: {{ session[18] | tojson }}
|
||
},
|
||
teamLogos: {
|
||
home: {{ session[15] | tojson }},
|
||
away: {{ session[19] | tojson }}
|
||
},
|
||
vmixTeamLogos: {
|
||
home: {{ session[16] | tojson }},
|
||
away: {{ session[20] | tojson }}
|
||
},
|
||
sessionToken: {{ session[3] | tojson }},
|
||
matchId: {{ session[1] | tojson }},
|
||
sourceKey: {{ source_key | tojson }},
|
||
isRussianCup: {{ is_russian_cup | tojson }},
|
||
|
||
homeFormations: {{ home_formations | tojson }},
|
||
awayFormations: {{ away_formations | tojson }},
|
||
|
||
homeStarting: {{ lineups.home_starting | tojson }},
|
||
awayStarting: {{ lineups.away_starting | tojson }},
|
||
homeBench: {{ lineups.home_bench | tojson }},
|
||
awayBench: {{ lineups.away_bench | tojson }},
|
||
|
||
homeCoaches: {{ coaches.home | tojson }},
|
||
awayCoaches: {{ coaches.away | tojson }},
|
||
|
||
referees: {{ referees | tojson }},
|
||
refereePool: {{ referee_pool | tojson }},
|
||
|
||
currentUser: {{ current_user | tojson }},
|
||
|
||
homeSquadPool: [],
|
||
awaySquadPool: [],
|
||
homeCoachPool: [],
|
||
awayCoachPool: []
|
||
};
|
||
</script>
|
||
|
||
<div class="formation-modal" id="clockEditorModal">
|
||
<div class="formation-modal-backdrop" onclick="closeClockEditor()"></div>
|
||
<div class="formation-modal-content clock-editor-modal-content">
|
||
<div class="formation-modal-header">
|
||
<div>
|
||
<div class="formation-modal-title">Редактировать время</div>
|
||
<div class="formation-modal-subtitle" id="clockEditorHint"></div>
|
||
</div>
|
||
<button type="button" class="modal-close-btn" onclick="closeClockEditor()">×</button>
|
||
</div>
|
||
|
||
<div class="formation-modal-body clock-editor-body">
|
||
<label class="clock-editor-label" for="clockEditorInput">Текущее матчевое время</label>
|
||
<input
|
||
type="text"
|
||
class="clock-editor-input"
|
||
id="clockEditorInput"
|
||
placeholder="12:30"
|
||
inputmode="numeric"
|
||
onkeydown="if (event.key === 'Enter') applyClockEditor(); if (event.key === 'Escape') closeClockEditor();"
|
||
/>
|
||
<div class="clock-editor-note">Можно ввести 12:30, 57:20 или 01:02:03.</div>
|
||
</div>
|
||
|
||
<div class="formation-modal-footer">
|
||
<button type="button" class="btn btn-secondary" onclick="closeClockEditor()">Отмена</button>
|
||
<button type="button" class="btn btn-primary" onclick="applyClockEditor()">Применить в интерфейсе и vMix</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="formation-modal" id="formationAssignModal">
|
||
<div
|
||
class="formation-modal-backdrop"
|
||
onclick="closeFormationAssignModal()"
|
||
></div>
|
||
|
||
<div class="formation-modal-content">
|
||
<div class="formation-modal-header">
|
||
<div>
|
||
<div class="formation-modal-title">Заполнение расстановки</div>
|
||
<div
|
||
class="formation-modal-subtitle"
|
||
id="formationAssignSubtitle"
|
||
></div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="modal-close-btn"
|
||
onclick="closeFormationAssignModal()"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div class="formation-modal-body">
|
||
<div id="formationAssignTableWrap"></div>
|
||
</div>
|
||
|
||
<div class="formation-modal-footer">
|
||
<button
|
||
type="button"
|
||
class="btn btn-cancel"
|
||
onclick="closeFormationAssignModal()"
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn btn-primary"
|
||
onclick="confirmFormationAssign()"
|
||
>
|
||
Применить игроков
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<div class="formation-modal" id="goalEditorModal">
|
||
<div class="formation-modal-backdrop" onclick="closeGoalEditor()"></div>
|
||
|
||
<div class="formation-modal-content" style="width: min(560px, 96vw);">
|
||
<div class="formation-modal-header">
|
||
<div>
|
||
<div class="formation-modal-title">Редактирование гола</div>
|
||
<div class="formation-modal-subtitle" id="goalEditorTitle"></div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary"
|
||
onclick="closeGoalEditor()"
|
||
>
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
|
||
<div class="formation-modal-body">
|
||
<div id="goalEditorList" class="goal-editor-list"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="formation-modal" id="squadEditorModal">
|
||
<div class="formation-modal-backdrop" onclick="closeSquadEditor()"></div>
|
||
|
||
<div class="formation-modal-content squad-editor-modal-content">
|
||
<div class="formation-modal-header">
|
||
<div>
|
||
<div class="formation-modal-title">Редактирование состава</div>
|
||
<div class="formation-modal-subtitle">
|
||
Добавление, удаление игроков, капитаны и тренеры
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="modal-close-btn"
|
||
onclick="closeSquadEditor()"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div class="formation-modal-body">
|
||
<div class="squad-editor-grid">
|
||
<div class="squad-editor-team">
|
||
<div class="squad-editor-team-title">{{ session[14] }}</div>
|
||
<div id="squadEditorHome"></div>
|
||
</div>
|
||
|
||
<div class="squad-editor-team">
|
||
<div class="squad-editor-team-title">{{ session[18] }}</div>
|
||
<div id="squadEditorAway"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="formation-modal-footer">
|
||
<button
|
||
type="button"
|
||
class="btn btn-cancel"
|
||
onclick="closeSquadEditor()"
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn btn-confirm"
|
||
onclick="applySquadEditorChanges()"
|
||
>
|
||
Сохранить изменения
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
const LOGIN_URL = "/login";
|
||
const LOGOUT_URL = "/logout";
|
||
const IDLE_LIMIT_MS = 2 * 60 * 60 * 1000;
|
||
const WARNING_BEFORE_MS = 60 * 1000;
|
||
const ACTIVITY_EVENTS = ["mousemove", "mousedown", "keydown", "scroll", "touchstart", "click"];
|
||
|
||
let lastActivityAt = Date.now();
|
||
let warned = false;
|
||
let redirected = false;
|
||
|
||
const banner = document.getElementById("sessionStatusBanner");
|
||
|
||
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());
|
||
}
|
||
|
||
function isHtmlNavigationRequest(input, init) {
|
||
const method = (init && init.method ? init.method : "GET").toUpperCase();
|
||
if (method !== "GET") return false;
|
||
const headers = new Headers((init && init.headers) || {});
|
||
const accept = headers.get("Accept") || "";
|
||
return accept.includes("text/html");
|
||
}
|
||
|
||
const nativeFetch = window.fetch.bind(window);
|
||
window.fetch = async function (input, init) {
|
||
const response = await nativeFetch(input, init);
|
||
if ([401, 403, 419, 440].includes(response.status)) {
|
||
showBanner("Сессия истекла. Выполняется переход на страницу входа…");
|
||
redirectToLogin("expired");
|
||
}
|
||
if (response.redirected && response.url && response.url.includes("/login")) {
|
||
redirectToLogin("expired");
|
||
}
|
||
if (!response.ok && isHtmlNavigationRequest(input, init)) {
|
||
const clone = response.clone();
|
||
try {
|
||
const text = await clone.text();
|
||
if (text && text.toLowerCase().includes("name=\"username\"")) {
|
||
redirectToLogin("expired");
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
return response;
|
||
};
|
||
|
||
document.addEventListener("submit", function (event) {
|
||
const form = event.target;
|
||
if (!(form instanceof HTMLFormElement)) return;
|
||
if (form.action && form.action.endsWith(LOGOUT_URL)) return;
|
||
markActivity();
|
||
}, true);
|
||
|
||
ACTIVITY_EVENTS.forEach((eventName) => {
|
||
window.addEventListener(eventName, markActivity, { passive: 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>
|
||
<script>
|
||
(function () {
|
||
const matchId = window.MATCH_DATA?.matchId;
|
||
const sessionToken = window.MATCH_DATA?.sessionToken;
|
||
const teamLogos = window.MATCH_DATA?.teamLogos || {};
|
||
const vmixTeamLogos = window.MATCH_DATA?.vmixTeamLogos || {};
|
||
const currentUserLogin = window.MATCH_DATA?.currentUser?.username || null;
|
||
|
||
if (!matchId || !sessionToken) {
|
||
return;
|
||
}
|
||
|
||
function formatCoachName(rawName) {
|
||
const parts = String(rawName || "").trim().split(/\s+/).filter(Boolean);
|
||
if (parts.length >= 2) {
|
||
return `${parts[1]} ${parts[0]}`;
|
||
}
|
||
return String(rawName || "").trim();
|
||
}
|
||
|
||
function formatPlayerName(firstName, lastName, fallbackName) {
|
||
const result = `${String(firstName || "").trim()} ${String(lastName || "").trim()}`.trim();
|
||
return result || String(fallbackName || "").trim();
|
||
}
|
||
|
||
async function sendNeutralPersonTitle(name, role, side) {
|
||
const inputName = "ИГРОК - НЕЙТРАЛЬНЫЙ";
|
||
|
||
const teamName = TEAM_NAMES?.[side] || "";
|
||
const logoPath = getVmixTeamLogo(teamName);
|
||
|
||
const commands = [
|
||
`/api/?Function=SetText&Input=${encodeURIComponent(inputName)}&SelectedName=${encodeURIComponent("Тренер.Text")}&Value=${encodeURIComponent(name || "")}`,
|
||
`/api/?Function=SetText&Input=${encodeURIComponent(inputName)}&SelectedName=${encodeURIComponent("Должность.Text")}&Value=${encodeURIComponent(role || "")}`,
|
||
`/api/?Function=SetImage&Input=${encodeURIComponent(inputName)}&SelectedName=${encodeURIComponent("Лого.Source")}&Value=${encodeURIComponent(logoPath || "")}`,
|
||
`/api/?Function=PreviewInput&Input=${encodeURIComponent(inputName)}`
|
||
];
|
||
|
||
try {
|
||
const response = await fetch("/api/vmix/publish-command", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json",
|
||
"X-Requested-With": "XMLHttpRequest"
|
||
},
|
||
credentials: "same-origin",
|
||
body: JSON.stringify({
|
||
match_id: matchId,
|
||
commands,
|
||
target_login: currentUserLogin,
|
||
meta: {
|
||
source: "person_click",
|
||
title_input: inputName,
|
||
person_name: name,
|
||
person_role: role,
|
||
side,
|
||
team_name: teamName,
|
||
logo_path: logoPath
|
||
}
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
console.error("Ошибка отправки титра в vMix:", response.status);
|
||
}
|
||
} catch (error) {
|
||
console.error("Не удалось отправить титр в vMix:", error);
|
||
}
|
||
}
|
||
|
||
document.addEventListener("click", function (event) {
|
||
const trigger = event.target.closest(".vmix-person-trigger");
|
||
if (!trigger) return;
|
||
|
||
const personType = String(trigger.dataset.vmixPersonType || "").trim();
|
||
const side = String(trigger.dataset.side || "").trim();
|
||
const rawName = String(trigger.dataset.name || "").trim();
|
||
const firstName = String(trigger.dataset.firstName || "").trim();
|
||
const lastName = String(trigger.dataset.lastName || "").trim();
|
||
const role = String(trigger.dataset.role || "").trim();
|
||
|
||
let formattedName = "";
|
||
|
||
if (personType === "coach") {
|
||
formattedName = formatCoachName(rawName);
|
||
} else {
|
||
formattedName = formatPlayerName(firstName, lastName, rawName);
|
||
}
|
||
|
||
if (!formattedName) return;
|
||
|
||
sendNeutralPersonTitle(formattedName, role, side);
|
||
});
|
||
})();
|
||
|
||
</script>
|
||
|
||
|
||
<script src="/static/script.js?v={{ static_version('script.js') }}"></script>
|
||
<div id="matrixLoader" class="matrix-loader" aria-hidden="true">
|
||
<canvas id="matrixCanvas" class="matrix-canvas"></canvas>
|
||
|
||
<div class="matrix-content">
|
||
<div class="matrix-title" id="matrixTitle">Загрузка данных матча</div>
|
||
<div class="matrix-status" id="matrixStatusText">
|
||
Подключение к сайту...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
(function () {
|
||
const loadForm = document.getElementById("loadMatchDataForm");
|
||
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 = [];
|
||
const fontSize = 18;
|
||
const chars = "アイウエオカキクケコサシスセソ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZЖФЛ<>+*#";
|
||
let columns = 0;
|
||
let lastTime = 0;
|
||
const fps = 18;
|
||
const interval = 1000 / fps;
|
||
|
||
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.12)";
|
||
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.985) {
|
||
drops[i] = 0;
|
||
}
|
||
|
||
// drops[i] += 0.65;
|
||
drops[i] ++;
|
||
}
|
||
|
||
matrixAnimationId = requestAnimationFrame(drawMatrix);
|
||
}
|
||
|
||
function startLoader(title, message) {
|
||
if (!loader) return;
|
||
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) return;
|
||
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);
|
||
|
||
if (!loadForm) return;
|
||
|
||
loadForm.addEventListener("submit", async function (event) {
|
||
event.preventDefault();
|
||
|
||
const submitButton = loadForm.querySelector('button[type="submit"]');
|
||
if (submitButton) {
|
||
submitButton.disabled = true;
|
||
}
|
||
|
||
startLoader("Загрузка данных матча", "Подключение к сайту...");
|
||
|
||
try {
|
||
const response = await fetch(loadForm.action, {
|
||
method: "POST",
|
||
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 || "Не удалось загрузить данные с сайта");
|
||
}
|
||
|
||
setLoaderState(
|
||
"is-success",
|
||
"Загрузка завершена",
|
||
(data.message || "Данные успешно загружены") + "\nПереход во вкладку «Игра»..."
|
||
);
|
||
|
||
setTimeout(() => {
|
||
window.location.href = data.redirect_url || "{{ '/admin/session/' ~ session[3] ~ '?tab=game' }}";
|
||
}, 1200);
|
||
|
||
} catch (error) {
|
||
setLoaderState(
|
||
"is-error",
|
||
"Ошибка загрузки",
|
||
(error.message || "Не удалось загрузить данные с сайта") + "\nВозврат во вкладку «Игра» через 3 секунды..."
|
||
);
|
||
|
||
setTimeout(() => {
|
||
window.location.href = "{{ '/admin/session/' ~ session[3] ~ '?tab=game' }}";
|
||
}, 3000);
|
||
} finally {
|
||
if (submitButton) {
|
||
submitButton.disabled = false;
|
||
}
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
<div class="formation-modal" id="clearMatchModal">
|
||
<div
|
||
class="formation-modal-backdrop"
|
||
onclick="closeClearMatchModal()"
|
||
></div>
|
||
|
||
<div class="formation-modal-content clear-match-modal-content">
|
||
<div class="formation-modal-header">
|
||
<div>
|
||
<div class="formation-modal-title">
|
||
Очистить матч
|
||
</div>
|
||
|
||
<div class="formation-modal-subtitle">
|
||
Это действие удалит все события, счет и время матча
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
class="modal-close-btn"
|
||
onclick="closeClearMatchModal()"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div class="formation-modal-body">
|
||
<div class="clear-match-warning">
|
||
Вы точно уверены, что хотите очистить данные матча?
|
||
</div>
|
||
</div>
|
||
|
||
<div class="formation-modal-footer">
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary"
|
||
onclick="closeClearMatchModal()"
|
||
>
|
||
Отмена
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
class="btn btn-danger"
|
||
onclick="confirmClearMatch()"
|
||
>
|
||
Очистить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|