Decider admin

This commit is contained in:
anibus
2026-10-24 22:02:29 +03:00
parent 3cad3aec4b
commit 0212f8b6d0
18 changed files with 1362 additions and 71 deletions
+652 -2
View File
@@ -1,6 +1,656 @@
@()(implicit r: Request[_])
@(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; }
<html>
/* Шапка: название слева, 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>
+1 -1
View File
@@ -61,7 +61,7 @@
</div>
<div style="clear: both"></div>
<div><i><b>Правила турнира "@deciderHumanName"</b><br/>
<p>@rules</p>
<p style="white-space: pre-wrap">@rules</p>
<br>
</i>
</div>