Files
Soulstorm-tournament-decider/app/views/admin.scala.html
T
2026-10-24 22:02:29 +03:00

657 lines
26 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.
@(deciders: Seq[repository.Decider])(implicit request: Request[_], messages: Messages)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="@{play.filters.csrf.CSRF.getToken.map(_.value).getOrElse("")}">
<title>Admin — Decider</title>
<link rel='stylesheet' href='@routes.Assets.at("lib/bootstrap/css/bootstrap.min.css")'>
<style>
body { padding-top: 70px; }
.decider-card { margin-bottom: 15px; border-left: 4px solid #337ab7; }
/* Строка карты занимает всю доступную ширину: выбор карты фиксированный, описание растягивается */
.map-row { margin-bottom: 5px; display: flex; align-items: center; }
.map-row .map-desc { flex: 1 1 auto; min-width: 0; width: auto; }
.map-row .map-preview, .map-row .btn-remove-map { flex: 0 0 auto; }
.modal-footer { text-align: right; }
.btn-remove-map { margin-left: 5px; }
.decider-key { font-family: monospace; color: #666; font-size: 0.9em; }
.nav-tabs > li > a { cursor: pointer; }
#deciderList { max-height: calc(100vh - 200px); overflow-y: auto; }
#deciderList .list-group-item { cursor: pointer; }
#deciderList .list-group-item.active { background-color: #337ab7; border-color: #337ab7; }
#deciderList .list-group-item:hover { background-color: #f5f5f5; }
#deciderList .list-group-item.active:hover { background-color: #286090; }
.error-msg { color: red; margin-top: 10px; }
.success-msg { color: green; margin-top: 10px; }
.map-preview { width: 64px; height: 40px; object-fit: cover; border-radius: 3px; margin-right: 6px; vertical-align: middle; }
/* Кнопка выбора карты вместо ручного ввода имени */
.btn-select-map { width: 170px; flex: 0 0 170px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 6px; }
.map-gallery { max-height: 60vh; overflow-y: auto; }
/* Поиск карт и загрузка картинки */
.map-picker-toolbar { display: flex; align-items: center; margin-bottom: 10px; }
.map-picker-toolbar .map-search { flex: 1 1 auto; margin-right: 8px; }
#mapUploadMessage { min-height: 18px; margin-bottom: 8px; }
.map-gallery .map-thumb { cursor: pointer; text-align: center; margin-bottom: 10px; padding: 4px; border: 1px solid transparent; border-radius: 4px; }
.map-gallery .map-thumb:hover { border-color: #337ab7; background-color: #f5f5f5; }
.map-gallery .map-thumb img { width: 100%; max-width: 160px; height: 100px; object-fit: cover; border-radius: 4px; }
.map-gallery .map-thumb-name { font-size: 11px; word-break: break-all; margin-top: 3px; }
#mapPickerTabs { margin-bottom: 15px; }
/* Шапка: название слева, Steam ID и Refresh справа, всё по центру по вертикали */
.navbar .container { display: flex; align-items: center; justify-content: space-between; }
.navbar-header { float: none; }
.navbar-user { display: flex; align-items: center; margin-left: 15px; color: #ccc; }
.navbar-user .navbar-user-id { margin-right: 10px; white-space: nowrap; }
.navbar-user .btn { margin: 0; }
/* Кнопки в заголовках панелей по центру строки с заголовком */
.panel-heading .panel-title { display: flex; align-items: center; justify-content: space-between; }
.panel-heading .panel-title > .btn { flex: 0 0 auto; margin-left: 10px; }
@@media (max-width: 480px) {
.navbar-user .navbar-user-id { display: none; }
}
</style>
</head>
<body>
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<a class="navbar-brand" href="/decider/admin">Decider admin</a>
</div>
<div class="navbar-user">
<span class="navbar-user-id">Steam ID: @(request.session.get("steamId").getOrElse("unknown"))</span>
<a href="/decider/admin" class="btn btn-sm btn-default">Refresh</a>
</div>
</div>
</nav>
<div class="container">
<div class="row">
<!-- Left panel: list of deciders -->
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
Deciders (@deciders.size)
<button class="btn btn-sm btn-success" onclick="openNewDecider()">+ New</button>
</h3>
</div>
<div class="list-group" id="deciderList">
@for(d <- deciders) {
<a class="list-group-item" onclick="openDecider('@d.key')">
<h4 class="list-group-item-heading">@d.name</h4>
<p class="list-group-item-text decider-key">@d.key &bull; maps: @d.maps.size</p>
</a>
}
@if(deciders.isEmpty) {
<div class="list-group-item text-muted">No deciders</div>
}
</div>
</div>
</div>
<!-- Right panel: edit form -->
<div class="col-md-8">
<div class="panel panel-default" id="editPanel" style="display: none;">
<div class="panel-heading">
<h3 class="panel-title">
<span id="editTitle">Edit</span>
<button class="btn btn-sm btn-danger" id="btnDelete" onclick="deleteCurrentDecider()">Delete</button>
</h3>
</div>
<div class="panel-body">
<form id="deciderForm">
<input type="hidden" id="deciderKey">
<div class="form-group">
<label for="formKey">Key</label>
<input type="text" class="form-control" id="formKey" placeholder="e.g. dawn_of_war">
<p class="help-block">Unique decider identifier (letters, digits, hyphen, underscore)</p>
</div>
<div class="form-group">
<label for="formName">Name</label>
<input type="text" class="form-control" id="formName" required>
</div>
<div class="form-group">
<label for="formRaceCount">Race Count</label>
<input type="number" class="form-control" id="formRaceCount" min="0">
</div>
<div class="form-group">
<label for="formRules">Rules</label>
<textarea class="form-control" id="formRules" rows="5"></textarea>
</div>
<div class="form-group">
<div class="checkbox">
<label>
<input type="checkbox" id="formExistAdditionalRaces"> Additional Races (Race count per team doubled)
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="formExistTextFieldRaces"> Exist Text Field Races (for Unification like mods)
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="formDescription">Map description
</label>
</div>
</div>
<h4>Maps</h4>
<div id="mapsContainer"></div>
<button type="button" class="btn btn-sm btn-primary" onclick="openMapPicker()">+ Add map</button>
<hr>
<div class="form-group text-right">
<button type="button" class="btn btn-default" onclick="closeEditPanel()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveDecider()">Save</button>
</div>
<div id="saveMessage"></div>
</form>
</div>
</div>
<div id="emptyState" class="text-muted text-center" style="padding: 50px;">
<h3>Select a decider to edit</h3>
<p>or create a new one</p>
</div>
</div>
</div>
</div>
<!-- Delete confirmation modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h4 class="modal-title">Confirm deletion</h4>
</div>
<div class="modal-body">
<p>Are you sure you want to delete decider <strong id="deleteDeciderName"></strong>?</p>
<p class="text-danger">This action cannot be undone!</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Delete</button>
</div>
</div>
</div>
</div>
<!-- Map image picker modal -->
<div class="modal fade" id="mapPickerModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h4 class="modal-title">Choose a map</h4>
</div>
<div class="modal-body">
<div class="map-picker-toolbar">
<input type="search" id="mapSearch" class="form-control map-search" placeholder="Search maps..." autocomplete="off">
<button type="button" class="btn btn-sm btn-primary" id="btnUploadMap">Upload image</button>
<input type="file" id="mapUploadInput" accept="image/jpeg,image/png,image/webp" style="display: none;">
</div>
<div id="mapUploadMessage"></div>
<ul class="nav nav-pills" id="mapPickerTabs"></ul>
<div class="row map-gallery" id="mapGallery"></div>
<div id="mapPickerEmpty" class="text-muted" style="display: none;">No map images found</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
<script src='@routes.Assets.at("lib/jquery/jquery.min.js")'></script>
<script src='@routes.Assets.at("lib/bootstrap/js/bootstrap.min.js")'></script>
<script src='@routes.Assets.at("lib/underscore/underscore.js")'></script>
<script>
var currentDeciderKey = null;
// CSRF: все AJAX-запросы отправляем с токеном из meta-тега,
// иначе Play отклоняет POST/DELETE с JSON-телом.
(function() {
var token = $('meta[name="csrf-token"]').attr('content');
if (token) {
$.ajaxSetup({ headers: { 'Csrf-Token': token } });
}
})();
// Open decider for editing
function openDecider(key) {
currentDeciderKey = key;
$.ajax({
url: '/decider/admin/deciders/' + encodeURIComponent(key),
method: 'GET',
success: function(decider) {
showEditPanel(decider);
},
error: function() {
alert('Failed to load decider');
}
});
}
// Open new decider form
function openNewDecider() {
currentDeciderKey = null;
showEditPanel({
key: '',
name: '',
rules: '',
raceCount: 1,
existAdditionalRaces: false,
existTextFieldRaces: false,
description: false,
maps: []
});
}
// Show edit panel with decider data
function showEditPanel(decider) {
$('#emptyState').hide();
$('#editPanel').show();
$('#editTitle').text(decider.key ? 'Edit: ' + decider.name : 'New decider');
$('#deciderKey').val(decider.key);
$('#formKey').val(decider.key).prop('readonly', !!decider.key);
$('#btnDelete').toggle(!!decider.key);
$('#formName').val(decider.name);
$('#formRules').val(decider.rules);
$('#formRaceCount').val(decider.raceCount);
$('#formExistAdditionalRaces').prop('checked', decider.existAdditionalRaces);
$('#formExistTextFieldRaces').prop('checked', decider.existTextFieldRaces);
$('#formDescription').prop('checked', decider.description);
// Render maps
var container = $('#mapsContainer');
container.empty();
if (decider.maps && decider.maps.length > 0) {
decider.maps.forEach(function(map, i) {
addMapRow(map.map, map.description);
});
} else {
addMapRow();
}
// Превью карт: подгружаем список картинок, чтобы найти файлы любого формата.
ensureMapImages();
// Highlight selected in list
$('.list-group-item').removeClass('active');
$('.list-group-item').filter(function() {
return $(this).find('.decider-key').text().includes(decider.key);
}).addClass('active');
}
// Close edit panel
function closeEditPanel() {
$('#editPanel').hide();
$('#emptyState').show();
currentDeciderKey = null;
}
// ---------- Map image picker ----------
var mapImages = null;
var pickerTargetRow = null;
var activeMapGroup = 2;
var mapSearchQuery = '';
// Группы карт: 1x1, 2x2, 3x3, 4x4 и остальные.
var mapGroups = [
{ players: 2, title: '1x1 (2p)' },
{ players: 4, title: '2x2 (4p)' },
{ players: 6, title: '3x3 (6p)' },
{ players: 8, title: '4x4 (8p)' },
{ players: 0, title: 'Other' }
];
function openMapPicker(targetRow) {
pickerTargetRow = targetRow || null;
mapSearchQuery = '';
$('#mapSearch').val('');
$('#mapUploadMessage').text('');
if (mapImages) {
showMapPicker();
return;
}
$.ajax({
url: '/decider/admin/maps',
method: 'GET',
success: function(data) {
mapImages = data;
showMapPicker();
},
error: function() {
alert('Failed to load map images');
}
});
}
function showMapPicker() {
// Если в текущей группе нет карт, открываем первую непустую.
if (mapsInGroup(activeMapGroup).length === 0) {
var firstNonEmpty = _.find(mapGroups, function(g) {
return mapsInGroup(g.players).length > 0;
});
activeMapGroup = firstNonEmpty ? firstNonEmpty.players : 2;
}
renderMapTabs();
renderMapGallery();
$('#mapPickerModal').modal('show');
}
function mapsInGroup(players) {
return (mapImages || []).filter(function(m) {
return players === 0
? [2, 4, 6, 8].indexOf(m.players) === -1
: m.players === players;
});
}
// Карты для галереи: при активном поиске ищем по всем группам сразу.
function visibleMaps() {
if (!mapSearchQuery) {
return mapsInGroup(activeMapGroup);
}
return (mapImages || []).filter(function(m) {
return (m.name || '').toLowerCase().indexOf(mapSearchQuery) !== -1 ||
(m.file || '').toLowerCase().indexOf(mapSearchQuery) !== -1;
});
}
function renderMapTabs() {
var tabs = $('#mapPickerTabs').empty();
// Во время поиска вкладки групп не нужны: показываются все совпадения.
if (mapSearchQuery) {
tabs.hide();
return;
}
tabs.show();
mapGroups.forEach(function(group) {
var count = mapsInGroup(group.players).length;
if (group.players === 0 && count === 0) return;
var link = $('<a href="#">')
.text(group.title + ' (' + count + ')')
.on('click', function(e) {
e.preventDefault();
activeMapGroup = group.players;
renderMapTabs();
renderMapGallery();
});
tabs.append($('<li>').toggleClass('active', group.players === activeMapGroup).append(link));
});
}
function renderMapGallery() {
var gallery = $('#mapGallery').empty();
var maps = visibleMaps();
maps.forEach(function(map) {
var thumb = $('<div class="map-thumb">')
.append($('<img>').attr('src', mapImageUrl(map.file)).attr('alt', map.name).attr('title', map.name))
.append($('<div class="map-thumb-name">').text(map.name))
.on('click', function() {
chooseMap(map.name);
});
gallery.append($('<div class="col-xs-6 col-sm-4 col-md-3">').append(thumb));
});
$('#mapPickerEmpty')
.text(mapSearchQuery ? 'No maps match your search' : 'No map images found')
.toggle(maps.length === 0);
}
// Перечитывает список картинок с сервера и перерисовывает галерею.
function reloadMapImages(preferredGroup) {
$.ajax({
url: '/decider/admin/maps',
method: 'GET',
success: function(data) {
mapImages = data;
if (preferredGroup !== undefined) {
activeMapGroup = preferredGroup;
}
renderMapTabs();
renderMapGallery();
},
error: function() {
alert('Failed to load map images');
}
});
}
// ---------- Поиск и загрузка картинок карт ----------
$('#mapSearch').on('input', function() {
mapSearchQuery = $(this).val().trim().toLowerCase();
renderMapTabs();
renderMapGallery();
});
$('#btnUploadMap').on('click', function() {
$('#mapUploadInput').click();
});
$('#mapUploadInput').on('change', function() {
var input = this;
var file = input.files && input.files[0];
if (!file) return;
var formData = new FormData();
formData.append('file', file);
var msgEl = $('#mapUploadMessage');
msgEl.removeClass('text-danger').addClass('text-muted').text('Uploading...');
$.ajax({
url: '/decider/admin/maps',
method: 'POST',
data: formData,
processData: false,
contentType: false,
success: function(image) {
msgEl.removeClass('text-danger').text('');
input.value = '';
mapSearchQuery = '';
$('#mapSearch').val('');
reloadMapImages(image ? image.players : undefined);
},
error: function(xhr) {
var error = 'Upload failed';
if (xhr.responseJSON && xhr.responseJSON.error) {
error = xhr.responseJSON.error;
}
msgEl.removeClass('text-muted').addClass('text-danger').text(error);
input.value = '';
}
});
});
function chooseMap(mapName) {
if (pickerTargetRow && $.contains(document, pickerTargetRow[0])) {
pickerTargetRow.find('.map-name').val(mapName);
updateMapRow(pickerTargetRow);
} else {
addMapRow(mapName, '');
}
pickerTargetRow = null;
$('#mapPickerModal').modal('hide');
}
function mapImageUrl(file) {
return '/assets/images/maps/' + encodeURIComponent(file);
}
// ---------- Map rows ----------
// Add map row
function addMapRow(mapName, description) {
var row = $('<div class="map-row form-inline">');
var preview = $('<img class="map-preview" alt="">');
// Имя карты задаётся только через выбор из галереи, ручной ввод запрещён.
var nameInput = $('<input type="hidden" class="map-name">')
.val(mapName || '');
var descInput = $('<input type="text" class="form-control map-desc" placeholder="Description">')
.val(description || '');
var selectBtn = $('<button type="button" class="btn btn-sm btn-default btn-select-map">Select map</button>')
.on('click', function() {
openMapPicker(row);
});
var removeBtn = $('<button type="button" class="btn btn-sm btn-danger btn-remove-map" title="Remove map">&#10005;</button>')
.on('click', function() {
row.remove();
});
row.append(preview, nameInput, selectBtn, descInput, removeBtn);
$('#mapsContainer').append(row);
updateMapRow(row);
}
// Обновляет превью и подпись кнопки выбора карты
function updateMapRow(row) {
var mapName = row.find('.map-name').val().trim();
var selectBtn = row.find('.btn-select-map');
selectBtn.text(mapName || 'Select map').attr('title', mapName || 'Select map');
var img = row.find('.map-preview').off('error').off('load');
if (!mapName) {
img.hide().removeAttr('src');
return;
}
img.attr('src', mapImageUrl(mapImageFileFor(mapName))).show().on('error', function() {
$(this).hide();
});
}
// Имя файла картинки для превью: если список карт загружен, берём реальный
// файл (он может быть png/webp), иначе считаем, что это jpg.
function mapImageFileFor(mapName) {
var found = _.find(mapImages || [], function(m) {
return m.name === mapName;
});
if (found) return found.file;
return /\.(jpg|jpeg|png|webp)$/i.test(mapName) ? mapName : mapName + '.jpg';
}
// Догружает список картинок, чтобы превью находили файлы любого формата.
function ensureMapImages() {
if (mapImages) return;
$.ajax({
url: '/decider/admin/maps',
method: 'GET',
success: function(data) {
mapImages = data;
$('#mapsContainer .map-row').each(function() {
updateMapRow($(this));
});
}
});
}
// Remove map row
function removeMapRow(btn) {
$(btn).closest('.map-row').remove();
}
// Collect form data and save
function saveDecider() {
var key = $('#formKey').val().trim();
if (!key) {
alert('Key is required');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
alert('Key may contain only letters, digits, hyphen and underscore');
return;
}
var maps = [];
$('#mapsContainer .map-row').each(function() {
var mapName = $(this).find('.map-name').val().trim();
var desc = $(this).find('.map-desc').val().trim();
if (mapName) {
maps.push({ map: mapName, description: desc || null });
}
});
var data = {
key: key,
name: $('#formName').val().trim(),
rules: $('#formRules').val(),
raceCount: parseInt($('#formRaceCount').val()) || 1,
existAdditionalRaces: $('#formExistAdditionalRaces').is(':checked'),
existTextFieldRaces: $('#formExistTextFieldRaces').is(':checked'),
description: $('#formDescription').is(':checked'),
maps: maps
};
var msgEl = $('#saveMessage');
msgEl.html('<span class="text-muted">Saving...</span>');
var url = '/decider/admin/deciders';
var method = 'POST';
$.ajax({
url: url,
method: method,
contentType: 'application/json',
data: JSON.stringify(data),
success: function() {
msgEl.html('<span class="success-msg">Saved!</span>');
setTimeout(function() {
location.reload();
}, 1000);
},
error: function(xhr) {
var error = '';
try { error = JSON.parse(xhr.responseText).error; } catch(e) {}
msgEl.html('<span class="error-msg">Error: ' + error + '</span>');
}
});
}
// Delete current decider
function deleteCurrentDecider() {
if (!currentDeciderKey) return;
var deciderName = $('#formName').val();
$('#deleteDeciderName').text(deciderName + ' (' + currentDeciderKey + ')');
$('#deleteModal').modal('show');
}
function confirmDelete() {
var key = currentDeciderKey;
$.ajax({
url: '/decider/admin/deciders/' + encodeURIComponent(key),
method: 'DELETE',
success: function() {
location.reload();
},
error: function() {
alert('Failed to delete decider');
}
});
}
// Initialize: if there's only one decider, open it automatically
$(document).ready(function() {
// Nothing to do on load - user selects from list
$('#mapPickerModal').on('hidden.bs.modal', function() {
pickerTargetRow = null;
});
});
</script>
</body>
</html>