Decider admin access to deciders by steam id

This commit is contained in:
anibus
2026-10-30 20:26:43 +03:00
parent 0212f8b6d0
commit f0ec7bbedb
8 changed files with 1896 additions and 165 deletions
+83 -63
View File
@@ -25,6 +25,27 @@ class AdminController @Inject()(
implicit val ec: ExecutionContext = cc.executionContext implicit val ec: ExecutionContext = cc.executionContext
private val logger = org.slf4j.LoggerFactory.getLogger("controllers.AdminController") private val logger = org.slf4j.LoggerFactory.getLogger("controllers.AdminController")
// ---------- Ownership ----------
private def currentSteamId[A](request: Request[A]): String =
SecuredActions.steamIdOrUnknown(request)
/** Редактировать и удалять десайдера может только его владелец. */
private def owns[A](decider: repository.Decider, request: Request[A]): Boolean =
decider.ownerSteamId.contains(currentSteamId(request))
private def deciderJson(d: repository.Decider): JsObject = Json.obj(
"key" -> d.key,
"name" -> d.name,
"rules" -> d.rules,
"raceCount" -> d.raceCount,
"existAdditionalRaces" -> d.existAdditionalRaces,
"existTextFieldRaces" -> d.existTextFieldRaces,
"description" -> d.description,
"ownerSteamId" -> d.ownerSteamId,
"maps" -> d.maps.map(m => Json.obj("map" -> m.map, "description" -> m.description))
)
// ---------- Steam OpenID ---------- // ---------- Steam OpenID ----------
private val SteamLoginUrl = "https://steamcommunity.com/openid/login" private val SteamLoginUrl = "https://steamcommunity.com/openid/login"
@@ -89,7 +110,7 @@ class AdminController @Inject()(
def index() = Secured { implicit request => def index() = Secured { implicit request =>
implicit val messages: Messages = messagesApi.preferred(request) implicit val messages: Messages = messagesApi.preferred(request)
Ok(views.html.admin(DeciderRepository.all)) Ok(views.html.admin(DeciderRepository.all.filter(d => owns(d, request))))
} }
// ---------- API: list map images ---------- // ---------- API: list map images ----------
@@ -130,16 +151,7 @@ class AdminController @Inject()(
// ---------- API: list all deciders ---------- // ---------- API: list all deciders ----------
def decidersList() = Secured { implicit request => def decidersList() = Secured { implicit request =>
val list = DeciderRepository.all.map(d => Json.obj( val list = DeciderRepository.all.filter(d => owns(d, request)).map(deciderJson)
"key" -> d.key,
"name" -> d.name,
"rules" -> d.rules,
"raceCount" -> d.raceCount,
"existAdditionalRaces" -> d.existAdditionalRaces,
"existTextFieldRaces" -> d.existTextFieldRaces,
"description" -> d.description,
"maps" -> d.maps.map(m => Json.obj("map" -> m.map, "description" -> m.description))
))
Ok(Json.toJson(list)) Ok(Json.toJson(list))
} }
@@ -147,17 +159,10 @@ class AdminController @Inject()(
def deciderGet(key: String) = Secured { implicit request => def deciderGet(key: String) = Secured { implicit request =>
DeciderRepository.get(key) match { DeciderRepository.get(key) match {
case Some(d) => case Some(d) if owns(d, request) =>
Ok(Json.obj( Ok(deciderJson(d))
"key" -> d.key, case Some(_) =>
"name" -> d.name, Forbidden(Json.obj("error" -> "You don't own this decider"))
"rules" -> d.rules,
"raceCount" -> d.raceCount,
"existAdditionalRaces" -> d.existAdditionalRaces,
"existTextFieldRaces" -> d.existTextFieldRaces,
"description" -> d.description,
"maps" -> d.maps.map(m => Json.obj("map" -> m.map, "description" -> m.description))
))
case None => case None =>
NotFound(Json.obj("error" -> s"Decider $key not found")) NotFound(Json.obj("error" -> s"Decider $key not found"))
} }
@@ -167,49 +172,59 @@ class AdminController @Inject()(
def deciderSave() = SecuredJson { implicit request => def deciderSave() = SecuredJson { implicit request =>
val json = request.body val json = request.body
val steamId = currentSteamId(request)
(json \ "key").asOpt[String] match { (json \ "key").asOpt[String] match {
case None => case None =>
BadRequest(Json.obj("error" -> "Missing key")) BadRequest(Json.obj("error" -> "Missing key"))
case Some(key) => case Some(key) =>
Try { val existing = DeciderRepository.get(key)
val maps = (json \ "maps").as[List[JsValue]].map { m => if (existing.exists(d => !owns(d, request))) {
repository.DeciderMapConfig( Forbidden(Json.obj("error" -> "You don't own this decider"))
map = (m \ "map").as[String], } else {
description = (m \ "description").asOpt[String] val name = (json \ "name").asOpt[String].map(_.trim).getOrElse("")
) val duplicateName = existing.isEmpty && name.nonEmpty &&
} DeciderRepository.all.exists(_.name.trim.equalsIgnoreCase(name))
repository.Decider( if (duplicateName) {
key = key, Conflict(Json.obj("error" -> s"A decider named '$name' already exists"))
name = (json \ "name").as[String], } else {
rules = (json \ "rules").as[String], Try {
raceCount = (json \ "raceCount").as[Int], val maps = (json \ "maps").as[List[JsValue]].map { m =>
existAdditionalRaces = (json \ "existAdditionalRaces").as[Boolean], repository.DeciderMapConfig(
existTextFieldRaces = (json \ "existTextFieldRaces").as[Boolean], map = (m \ "map").as[String],
description = (json \ "description").as[Boolean], description = (m \ "description").asOpt[String]
maps = maps )
) }
}.toEither match { repository.Decider(
case Right(decider) => key = key,
val existed = DeciderRepository.get(decider.key).isDefined name = (json \ "name").as[String],
DeciderRepository.save(decider) rules = (json \ "rules").as[String],
DeciderRepository.reload() raceCount = (json \ "raceCount").as[Int],
if (existed) { existAdditionalRaces = (json \ "existAdditionalRaces").as[Boolean],
logger.info( existTextFieldRaces = (json \ "existTextFieldRaces").as[Boolean],
s"Admin ${SecuredActions.steamIdOrUnknown(request)} updated decider '${decider.key}' " + description = (json \ "description").as[Boolean],
s"(name='${decider.name}', maps=${decider.maps.size})" maps = maps,
// Владельца из запроса игнорируем: он либо уже назначен, либо
// им становится автор нового десайдера.
ownerSteamId = existing.flatMap(_.ownerSteamId).orElse(Some(steamId))
) )
} else { }.toEither match {
logger.info( case Right(decider) =>
s"Admin ${SecuredActions.steamIdOrUnknown(request)} created decider '${decider.key}' " + DeciderRepository.save(decider)
s"(name='${decider.name}', maps=${decider.maps.size})" DeciderRepository.reload()
) val action = if (existing.isDefined) "updated" else "created"
} logger.info(
Ok(Json.obj("ok" -> true)) s"Admin $steamId $action decider '${decider.key}' " +
s"(name='${decider.name}', maps=${decider.maps.size}, " +
s"owner=${decider.ownerSteamId.getOrElse("-")})"
)
Ok(Json.obj("ok" -> true))
case Left(error) => case Left(error) =>
BadRequest(Json.obj("error" -> s"$error")) BadRequest(Json.obj("error" -> s"$error"))
}
}
} }
} }
} }
@@ -217,12 +232,17 @@ class AdminController @Inject()(
// ---------- API: delete decider ---------- // ---------- API: delete decider ----------
def deciderDelete(key: String) = SecuredApi { implicit request => def deciderDelete(key: String) = SecuredApi { implicit request =>
if (DeciderRepository.delete(key)) { DeciderRepository.get(key) match {
DeciderRepository.reload() case Some(d) if !owns(d, request) =>
logger.info(s"Admin ${SecuredActions.steamIdOrUnknown(request)} deleted decider '$key'") Forbidden(Json.obj("error" -> "You don't own this decider"))
Ok(Json.obj("ok" -> true)) case _ =>
} else { if (DeciderRepository.delete(key)) {
NotFound(Json.obj("error" -> s"Decider $key not found")) DeciderRepository.reload()
logger.info(s"Admin ${currentSteamId(request)} deleted decider '$key'")
Ok(Json.obj("ok" -> true))
} else {
NotFound(Json.obj("error" -> s"Decider $key not found"))
}
} }
} }
+38
View File
@@ -0,0 +1,38 @@
package controllers
import java.io.File
import javax.inject.{Inject, Singleton}
import play.api.mvc.{AbstractController, ControllerComponents}
import repository.MapImageRepository
import scala.concurrent.ExecutionContext
/**
* Раздаёт картинки карт из каталога, заданного в конфиге (`maps.imagesDir`).
*
* Нужен потому, что каталог может находиться вне `public`/`app/assets`
* (например, на сервере), и стандартный Assets его не отдаст.
*/
@Singleton
class MapImageController @Inject()(cc: ControllerComponents) extends AbstractController(cc) {
implicit val ec: ExecutionContext = cc.executionContext
def image(file: String) = Action { implicit request =>
MapImageRepository.findFile(file) match {
case Some(image) =>
Ok.sendFile(image, inline = true)
.as(contentType(image))
.withHeaders(CACHE_CONTROL -> "public, max-age=86400")
case None => NotFound
}
}
private def contentType(file: File): String = {
val name = file.getName.toLowerCase
if (name.endsWith(".png")) "image/png"
else if (name.endsWith(".webp")) "image/webp"
else "image/jpeg"
}
}
+17 -2
View File
@@ -18,7 +18,9 @@ case class Decider(
existAdditionalRaces: Boolean, existAdditionalRaces: Boolean,
existTextFieldRaces: Boolean, existTextFieldRaces: Boolean,
description: Boolean, description: Boolean,
maps: List[DeciderMapConfig] maps: List[DeciderMapConfig],
/** Steam ID владельца: только он может редактировать и удалять десайдера. */
ownerSteamId: Option[String] = None
) )
/** /**
@@ -32,6 +34,12 @@ case class Decider(
*/ */
object DeciderRepository { object DeciderRepository {
/**
* Владелец по умолчанию. Записи без ownerSteamId (например, из старого
* application.conf) считаются принадлежащими этому Steam ID.
*/
val DefaultOwnerSteamId: String = "76561198137977374"
private def appConfig: Config = ConfigFactory.load() private def appConfig: Config = ConfigFactory.load()
/** Файл, в который админка пишет десайдеров. */ /** Файл, в который админка пишет десайдеров. */
@@ -88,7 +96,11 @@ object DeciderRepository {
existAdditionalRaces = Try(d.getBoolean("existAdditionalRaces")).getOrElse(false), existAdditionalRaces = Try(d.getBoolean("existAdditionalRaces")).getOrElse(false),
existTextFieldRaces = Try(d.getBoolean("existTextFieldRaces")).getOrElse(false), existTextFieldRaces = Try(d.getBoolean("existTextFieldRaces")).getOrElse(false),
description = Try(d.getBoolean("description")).getOrElse(false), description = Try(d.getBoolean("description")).getOrElse(false),
maps = maps maps = maps,
ownerSteamId = Try(d.getString("ownerSteamId")).toOption
.map(_.trim)
.filter(_.nonEmpty)
.orElse(Some(DefaultOwnerSteamId))
)) ))
} }
} }
@@ -122,6 +134,9 @@ object DeciderRepository {
deciders.foreach { d => deciders.foreach { d =>
sb.append(" ").append(quoteKey(d.key)).append(" {\n") sb.append(" ").append(quoteKey(d.key)).append(" {\n")
sb.append(" name = ").append(quote(d.name)).append('\n') sb.append(" name = ").append(quote(d.name)).append('\n')
sb.append(" ownerSteamId = ")
.append(quote(d.ownerSteamId.getOrElse(DefaultOwnerSteamId)))
.append('\n')
sb.append(" raceCount = ").append(d.raceCount).append('\n') sb.append(" raceCount = ").append(d.raceCount).append('\n')
if (d.existAdditionalRaces) sb.append(" existAdditionalRaces = true\n") if (d.existAdditionalRaces) sb.append(" existAdditionalRaces = true\n")
if (d.existTextFieldRaces) sb.append(" existTextFieldRaces = true\n") if (d.existTextFieldRaces) sb.append(" existTextFieldRaces = true\n")
+66 -94
View File
@@ -1,19 +1,20 @@
package repository package repository
import java.io.File import java.io.File
import java.net.{JarURLConnection, URL}
import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption
import scala.collection.JavaConverters._
import com.typesafe.config.ConfigFactory
import scala.util.Try import scala.util.Try
/** Изображение карты, доступное для выбора в админке. */ /** Изображение карты, доступное для выбора в админке. */
case class MapImage(name: String, file: String, players: Int) case class MapImage(name: String, file: String, players: Int)
/** /**
* Список картинок карт из app/assets/images/maps. * Список картинок карт из каталога, заданного в конфиге (`maps.imagesDir`).
* *
* Картинки читаются из исходников (при запуске через `sbt run`) либо из * Каталог читается при каждом обращении, поэтому загруженные через админку
* classpath (когда приложение собрано и assets упакованы в jar). * картинки сразу появляются в галерее без перезапуска приложения.
*/ */
object MapImageRepository { object MapImageRepository {
@@ -23,36 +24,62 @@ object MapImageRepository {
private val IgnoredFiles = Set("default.jpg") private val IgnoredFiles = Set("default.jpg")
private val IgnoredPattern = """^img(_\d+)?\.(jpg|jpeg|png|webp)$""".r private val IgnoredPattern = """^img(_\d+)?\.(jpg|jpeg|png|webp)$""".r
/** Каталоги с картинками: исходники и результат сборки assets. */ /** Ключ конфига с каталогом картинок карт. */
private val MapDirectories = Seq( private val ConfigKey = "maps.imagesDir"
"app/assets/images/maps",
"target/web/public/main/images/maps",
"public/images/maps"
)
private val ClasspathDirectory = "public/images/maps" /** Значение по умолчанию, если ключ в конфиге отсутствует. */
private val DefaultImagesDirectory = "app/assets/images/maps"
/** Каталог, в который сохраняются загруженные картинки (исходники). */ /** Каталог с картинками карт из конфига. */
private val SourceUploadDirectory = "app/assets/images/maps" def imagesDirectory: File = {
val configured = Try(ConfigFactory.load().getString(ConfigKey)).toOption
/** Каталоги, из которых Play раздаёт assets в dev-режиме. */ .map(_.trim)
private val RuntimeUploadDirectory = "target/web/public/main/images/maps" .filter(_.nonEmpty)
.getOrElse(DefaultImagesDirectory)
new File(configured)
}
/** Максимальный размер загружаемого файла. */ /** Максимальный размер загружаемого файла. */
private val MaxUploadBytes = 10L * 1024 * 1024 private val MaxUploadBytes = 10L * 1024 * 1024
/** Все карты, отсортированные по количеству игроков и имени. */ /** Все карты, отсортированные по количеству игроков и имени. */
def all: Seq[MapImage] = def all: Seq[MapImage] = {
loadFileNames() val directory = imagesDirectory
.distinct if (!directory.isDirectory) Seq.empty
.map(toMapImage) else
.sortBy(image => (groupOrder(image.players), image.name.toLowerCase)) Option(directory.listFiles()).toSeq.flatten
.filter(_.isFile)
.map(_.getName)
.filter(isMapImage)
.distinct
.map(toMapImage)
.sortBy(image => (groupOrder(image.players), image.name.toLowerCase))
}
/**
* Ищет картинку карты по имени файла.
*
* Используется для раздачи картинок через контроллер: имя проверяется так,
* чтобы запрос не мог выйти за пределы каталога из конфига.
*/
def findFile(fileName: String): Option[File] = {
val name = Option(fileName).map(_.trim).getOrElse("")
if (name.isEmpty || name != fileName || !isMapImage(name)) None
else {
val directory = imagesDirectory
val parent = Try(directory.getCanonicalFile).toOption
val candidate = Try(new File(directory, name).getCanonicalFile).toOption
(parent, candidate) match {
case (Some(dir), Some(file)) if file.getParentFile == dir && file.isFile => Some(file)
case _ => None
}
}
}
/** /**
* Сохраняет загруженную картинку карты и возвращает её описание. * Сохраняет загруженную картинку карты и возвращает её описание.
* *
* Файл пишется и в исходники (app/assets), и в каталог, который раздаётся * Файл пишется в каталог из конфига, поэтому картинка сразу появляется
* в dev-режиме (target/web/public/main), чтобы картинка сразу появилась
* в галерее без перезапуска приложения. * в галерее без перезапуска приложения.
*/ */
def saveUploaded(rawFileName: String, source: File): Either[String, MapImage] = { def saveUploaded(rawFileName: String, source: File): Either[String, MapImage] = {
@@ -62,34 +89,23 @@ object MapImageRepository {
else sanitizeFileName(rawFileName) match { else sanitizeFileName(rawFileName) match {
case None => Left("Unsupported file (allowed types: jpg, jpeg, png, webp)") case None => Left("Unsupported file (allowed types: jpg, jpeg, png, webp)")
case Some(safeName) => case Some(safeName) =>
val directories = uploadDirectories() val directory = imagesDirectory
if (directories.isEmpty) Left("Map images directory not found") Try {
else { if (!directory.isDirectory) directory.mkdirs()
val fileName = uniqueFileName(safeName, directories) val fileName = uniqueFileName(safeName, directory)
Try { java.nio.file.Files.copy(
directories.foreach { dir => source.toPath,
java.nio.file.Files.copy( new File(directory, fileName).toPath,
source.toPath, StandardCopyOption.REPLACE_EXISTING
new File(dir, fileName).toPath, )
StandardCopyOption.REPLACE_EXISTING fileName
) }.toEither match {
} case Right(fileName) => Right(toMapImage(fileName))
}.toEither match { case Left(error) => Left(s"Failed to save file: ${error.getMessage}")
case Right(_) => Right(toMapImage(fileName))
case Left(error) => Left(s"Failed to save file: ${error.getMessage}")
}
} }
} }
} }
/** Каталоги для записи: исходники создаются при необходимости, остальные — только существующие. */
private def uploadDirectories(): Seq[File] = {
val source = new File(SourceUploadDirectory)
if (!source.isDirectory) source.mkdirs()
(source +: Seq(new File(RuntimeUploadDirectory)))
.filter(_.isDirectory)
}
/** Проверяет расширение и убирает из имени всё, что может сломать путь. */ /** Проверяет расширение и убирает из имени всё, что может сломать путь. */
private def sanitizeFileName(rawFileName: String): Option[String] = { private def sanitizeFileName(rawFileName: String): Option[String] = {
val name = Option(rawFileName) val name = Option(rawFileName)
@@ -105,8 +121,8 @@ object MapImageRepository {
} }
/** Подбирает свободное имя, добавляя суффикс -1, -2, ... при совпадении. */ /** Подбирает свободное имя, добавляя суффикс -1, -2, ... при совпадении. */
private def uniqueFileName(fileName: String, directories: Seq[File]): String = { private def uniqueFileName(fileName: String, directory: File): String = {
def taken(name: String): Boolean = directories.exists(dir => new File(dir, name).exists()) def taken(name: String): Boolean = new File(directory, name).exists()
if (!taken(fileName)) fileName if (!taken(fileName)) fileName
else { else {
@@ -120,17 +136,6 @@ object MapImageRepository {
} }
} }
private def loadFileNames(): Seq[String] = {
val fromDisk = MapDirectories
.map(new File(_))
.find(_.isDirectory)
.map(dir => Option(dir.listFiles()).toSeq.flatten.filter(_.isFile).map(_.getName))
.getOrElse(Seq.empty)
val fileNames = if (fromDisk.nonEmpty) fromDisk else loadFromClasspath()
fileNames.filter(isMapImage)
}
private def isMapImage(fileName: String): Boolean = { private def isMapImage(fileName: String): Boolean = {
val lower = fileName.toLowerCase val lower = fileName.toLowerCase
ImageExtensions.exists(ext => lower.endsWith("." + ext)) && ImageExtensions.exists(ext => lower.endsWith("." + ext)) &&
@@ -138,39 +143,6 @@ object MapImageRepository {
IgnoredPattern.findFirstIn(lower).isEmpty IgnoredPattern.findFirstIn(lower).isEmpty
} }
private def loadFromClasspath(): Seq[String] = {
val loader = Option(getClass.getClassLoader).getOrElse(ClassLoader.getSystemClassLoader)
loader.getResources(ClasspathDirectory).asScala.toSeq.flatMap(readResource)
}
private def readResource(url: URL): Seq[String] = url.getProtocol match {
case "file" =>
Try(new File(url.toURI)).toOption
.filter(_.isDirectory)
.flatMap(dir => Option(dir.listFiles()))
.toSeq
.flatten
.filter(_.isFile)
.map(_.getName)
case "jar" =>
Try {
val connection = url.openConnection().asInstanceOf[JarURLConnection]
connection.setUseCaches(false)
val jar = connection.getJarFile
try {
jar.entries().asScala
.map(_.getName)
.filter(_.startsWith(ClasspathDirectory + "/"))
.map(_.stripPrefix(ClasspathDirectory + "/"))
.filterNot(_.contains("/"))
.toSeq
} finally jar.close()
}.getOrElse(Seq.empty)
case _ => Seq.empty
}
private def toMapImage(fileName: String): MapImage = { private def toMapImage(fileName: String): MapImage = {
val baseName = fileName.substring(0, fileName.lastIndexOf('.')) val baseName = fileName.substring(0, fileName.lastIndexOf('.'))
MapImage(baseName, fileName, playersFrom(fileName)) MapImage(baseName, fileName, playersFrom(fileName))
+77 -6
View File
@@ -81,9 +81,9 @@
</div> </div>
<div class="list-group" id="deciderList"> <div class="list-group" id="deciderList">
@for(d <- deciders) { @for(d <- deciders) {
<a class="list-group-item" onclick="openDecider('@d.key')"> <a class="list-group-item" data-key="@d.key" onclick="openDecider('@d.key')">
<h4 class="list-group-item-heading">@d.name</h4> <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> <p class="list-group-item-text decider-key">@d.key &bull; maps: @d.maps.size &bull; owner: @d.ownerSteamId.getOrElse("-")</p>
</a> </a>
} }
@if(deciders.isEmpty) { @if(deciders.isEmpty) {
@@ -110,6 +110,17 @@
<label for="formKey">Key</label> <label for="formKey">Key</label>
<input type="text" class="form-control" id="formKey" placeholder="e.g. dawn_of_war"> <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> <p class="help-block">Unique decider identifier (letters, digits, hyphen, underscore)</p>
<p class="help-block" id="keyUrlBlock" style="display: none;">
URL: <a id="formKeyUrl" href="#" target="_blank" rel="noopener"></a>
<button type="button" class="btn btn-xs btn-default" id="btnCopyUrl"
title="Copy URL to clipboard">Copy</button>
</p>
</div>
<div class="form-group">
<label for="formOwner">Owner Steam ID</label>
<input type="text" class="form-control" id="formOwner" readonly>
<p class="help-block">Owner cannot be changed</p>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -218,6 +229,8 @@
<script src='@routes.Assets.at("lib/underscore/underscore.js")'></script> <script src='@routes.Assets.at("lib/underscore/underscore.js")'></script>
<script> <script>
var currentDeciderKey = null; var currentDeciderKey = null;
// Steam ID текущего пользователя: владелец нового десайдера.
var currentSteamId = '@request.session.get("steamId").getOrElse("")';
// CSRF: все AJAX-запросы отправляем с токеном из meta-тега, // CSRF: все AJAX-запросы отправляем с токеном из meta-тега,
// иначе Play отклоняет POST/DELETE с JSON-телом. // иначе Play отклоняет POST/DELETE с JSON-телом.
@@ -266,7 +279,21 @@
$('#deciderKey').val(decider.key); $('#deciderKey').val(decider.key);
$('#formKey').val(decider.key).prop('readonly', !!decider.key); $('#formKey').val(decider.key).prop('readonly', !!decider.key);
// Ссылка на десайдер: показываем только для уже созданных.
if (decider.key) {
var deciderPath = '/decider/' + encodeURIComponent(decider.key);
$('#formKeyUrl')
.attr('href', deciderPath)
.text(window.location.origin + deciderPath);
$('#keyUrlBlock').show();
} else {
$('#formKeyUrl').attr('href', '#').text('');
$('#keyUrlBlock').hide();
}
$('#btnDelete').toggle(!!decider.key); $('#btnDelete').toggle(!!decider.key);
$('#formOwner').val(decider.ownerSteamId || currentSteamId);
$('#formName').val(decider.name); $('#formName').val(decider.name);
$('#formRules').val(decider.rules); $('#formRules').val(decider.rules);
$('#formRaceCount').val(decider.raceCount); $('#formRaceCount').val(decider.raceCount);
@@ -288,11 +315,13 @@
// Превью карт: подгружаем список картинок, чтобы найти файлы любого формата. // Превью карт: подгружаем список картинок, чтобы найти файлы любого формата.
ensureMapImages(); ensureMapImages();
// Highlight selected in list // Highlight selected in list (точное совпадение по ключу десайдера)
$('.list-group-item').removeClass('active'); $('.list-group-item').removeClass('active');
$('.list-group-item').filter(function() { if (decider.key) {
return $(this).find('.decider-key').text().includes(decider.key); $('.list-group-item').filter(function() {
}).addClass('active'); return $(this).attr('data-key') === decider.key;
}).addClass('active');
}
} }
// Close edit panel // Close edit panel
@@ -644,12 +673,54 @@
}); });
} }
// Копирование ссылки на десайдер в буфер обмена.
// navigator.clipboard доступен только в защищённом контексте (https/localhost),
// поэтому есть запасной вариант через скрытую textarea.
function copyDeciderUrl() {
var url = $('#formKeyUrl').text();
if (!url) return;
function done(ok) {
var btn = $('#btnCopyUrl');
btn.text(ok ? 'Copied!' : 'Copy failed');
setTimeout(function() { btn.text('Copy'); }, 1500);
}
function fallbackCopy() {
var textarea = $('<textarea>').val(url).css({
position: 'fixed',
top: 0,
left: 0,
opacity: 0
}).appendTo('body');
textarea[0].select();
var ok = false;
try {
ok = document.execCommand('copy');
} catch (e) {
ok = false;
}
textarea.remove();
done(ok);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(function() {
done(true);
}, fallbackCopy);
} else {
fallbackCopy();
}
}
// Initialize: if there's only one decider, open it automatically // Initialize: if there's only one decider, open it automatically
$(document).ready(function() { $(document).ready(function() {
// Nothing to do on load - user selects from list // Nothing to do on load - user selects from list
$('#mapPickerModal').on('hidden.bs.modal', function() { $('#mapPickerModal').on('hidden.bs.modal', function() {
pickerTargetRow = null; pickerTargetRow = null;
}); });
$('#btnCopyUrl').on('click', copyDeciderUrl);
}); });
</script> </script>
</body> </body>
+7
View File
@@ -14,6 +14,13 @@ admin {
decidersFile = "conf/deciders.conf" decidersFile = "conf/deciders.conf"
} }
# Каталог с картинками карт: из него читается галерея в админке и в него
# сохраняются загруженные картинки. Путь может быть абсолютным или
# относительным (от рабочего каталога приложения).
maps {
imagesDir = "app/assets/images/maps"
}
deciders{ deciders{
classic{ classic{
maps = [["2p_battle_marshes"], maps = [["2p_battle_marshes"],
+1604
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -21,5 +21,9 @@ DELETE /decider/admin/deciders/:key controllers.AdminController.deciderDel
GET /decider/:name controllers.HomeController.index(name) GET /decider/:name controllers.HomeController.index(name)
# Картинки карт из каталога maps.imagesDir.
# ВАЖНО: маршрут должен идти ДО /assets/*file, иначе его перехватит Assets.
GET /assets/images/maps/:file controllers.MapImageController.image(file)
# Map static resources from the /public folder to the /assets URL path # Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.at(path="/public", file) GET /assets/*file controllers.Assets.at(path="/public", file)