Decider admin access to deciders by steam id
This commit is contained in:
@@ -25,6 +25,27 @@ class AdminController @Inject()(
|
||||
implicit val ec: ExecutionContext = cc.executionContext
|
||||
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 ----------
|
||||
|
||||
private val SteamLoginUrl = "https://steamcommunity.com/openid/login"
|
||||
@@ -89,7 +110,7 @@ class AdminController @Inject()(
|
||||
|
||||
def index() = Secured { implicit 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 ----------
|
||||
@@ -130,16 +151,7 @@ class AdminController @Inject()(
|
||||
// ---------- API: list all deciders ----------
|
||||
|
||||
def decidersList() = Secured { implicit request =>
|
||||
val list = DeciderRepository.all.map(d => Json.obj(
|
||||
"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))
|
||||
))
|
||||
val list = DeciderRepository.all.filter(d => owns(d, request)).map(deciderJson)
|
||||
Ok(Json.toJson(list))
|
||||
}
|
||||
|
||||
@@ -147,17 +159,10 @@ class AdminController @Inject()(
|
||||
|
||||
def deciderGet(key: String) = Secured { implicit request =>
|
||||
DeciderRepository.get(key) match {
|
||||
case Some(d) =>
|
||||
Ok(Json.obj(
|
||||
"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))
|
||||
))
|
||||
case Some(d) if owns(d, request) =>
|
||||
Ok(deciderJson(d))
|
||||
case Some(_) =>
|
||||
Forbidden(Json.obj("error" -> "You don't own this decider"))
|
||||
case None =>
|
||||
NotFound(Json.obj("error" -> s"Decider $key not found"))
|
||||
}
|
||||
@@ -167,49 +172,59 @@ class AdminController @Inject()(
|
||||
|
||||
def deciderSave() = SecuredJson { implicit request =>
|
||||
val json = request.body
|
||||
val steamId = currentSteamId(request)
|
||||
|
||||
(json \ "key").asOpt[String] match {
|
||||
case None =>
|
||||
BadRequest(Json.obj("error" -> "Missing key"))
|
||||
|
||||
case Some(key) =>
|
||||
Try {
|
||||
val maps = (json \ "maps").as[List[JsValue]].map { m =>
|
||||
repository.DeciderMapConfig(
|
||||
map = (m \ "map").as[String],
|
||||
description = (m \ "description").asOpt[String]
|
||||
)
|
||||
}
|
||||
repository.Decider(
|
||||
key = key,
|
||||
name = (json \ "name").as[String],
|
||||
rules = (json \ "rules").as[String],
|
||||
raceCount = (json \ "raceCount").as[Int],
|
||||
existAdditionalRaces = (json \ "existAdditionalRaces").as[Boolean],
|
||||
existTextFieldRaces = (json \ "existTextFieldRaces").as[Boolean],
|
||||
description = (json \ "description").as[Boolean],
|
||||
maps = maps
|
||||
)
|
||||
}.toEither match {
|
||||
case Right(decider) =>
|
||||
val existed = DeciderRepository.get(decider.key).isDefined
|
||||
DeciderRepository.save(decider)
|
||||
DeciderRepository.reload()
|
||||
if (existed) {
|
||||
logger.info(
|
||||
s"Admin ${SecuredActions.steamIdOrUnknown(request)} updated decider '${decider.key}' " +
|
||||
s"(name='${decider.name}', maps=${decider.maps.size})"
|
||||
val existing = DeciderRepository.get(key)
|
||||
if (existing.exists(d => !owns(d, request))) {
|
||||
Forbidden(Json.obj("error" -> "You don't own this decider"))
|
||||
} else {
|
||||
val name = (json \ "name").asOpt[String].map(_.trim).getOrElse("")
|
||||
val duplicateName = existing.isEmpty && name.nonEmpty &&
|
||||
DeciderRepository.all.exists(_.name.trim.equalsIgnoreCase(name))
|
||||
if (duplicateName) {
|
||||
Conflict(Json.obj("error" -> s"A decider named '$name' already exists"))
|
||||
} else {
|
||||
Try {
|
||||
val maps = (json \ "maps").as[List[JsValue]].map { m =>
|
||||
repository.DeciderMapConfig(
|
||||
map = (m \ "map").as[String],
|
||||
description = (m \ "description").asOpt[String]
|
||||
)
|
||||
}
|
||||
repository.Decider(
|
||||
key = key,
|
||||
name = (json \ "name").as[String],
|
||||
rules = (json \ "rules").as[String],
|
||||
raceCount = (json \ "raceCount").as[Int],
|
||||
existAdditionalRaces = (json \ "existAdditionalRaces").as[Boolean],
|
||||
existTextFieldRaces = (json \ "existTextFieldRaces").as[Boolean],
|
||||
description = (json \ "description").as[Boolean],
|
||||
maps = maps,
|
||||
// Владельца из запроса игнорируем: он либо уже назначен, либо
|
||||
// им становится автор нового десайдера.
|
||||
ownerSteamId = existing.flatMap(_.ownerSteamId).orElse(Some(steamId))
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
s"Admin ${SecuredActions.steamIdOrUnknown(request)} created decider '${decider.key}' " +
|
||||
s"(name='${decider.name}', maps=${decider.maps.size})"
|
||||
)
|
||||
}
|
||||
Ok(Json.obj("ok" -> true))
|
||||
}.toEither match {
|
||||
case Right(decider) =>
|
||||
DeciderRepository.save(decider)
|
||||
DeciderRepository.reload()
|
||||
val action = if (existing.isDefined) "updated" else "created"
|
||||
logger.info(
|
||||
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) =>
|
||||
BadRequest(Json.obj("error" -> s"$error"))
|
||||
case Left(error) =>
|
||||
BadRequest(Json.obj("error" -> s"$error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,12 +232,17 @@ class AdminController @Inject()(
|
||||
// ---------- API: delete decider ----------
|
||||
|
||||
def deciderDelete(key: String) = SecuredApi { implicit request =>
|
||||
if (DeciderRepository.delete(key)) {
|
||||
DeciderRepository.reload()
|
||||
logger.info(s"Admin ${SecuredActions.steamIdOrUnknown(request)} deleted decider '$key'")
|
||||
Ok(Json.obj("ok" -> true))
|
||||
} else {
|
||||
NotFound(Json.obj("error" -> s"Decider $key not found"))
|
||||
DeciderRepository.get(key) match {
|
||||
case Some(d) if !owns(d, request) =>
|
||||
Forbidden(Json.obj("error" -> "You don't own this decider"))
|
||||
case _ =>
|
||||
if (DeciderRepository.delete(key)) {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ case class Decider(
|
||||
existAdditionalRaces: Boolean,
|
||||
existTextFieldRaces: Boolean,
|
||||
description: Boolean,
|
||||
maps: List[DeciderMapConfig]
|
||||
maps: List[DeciderMapConfig],
|
||||
/** Steam ID владельца: только он может редактировать и удалять десайдера. */
|
||||
ownerSteamId: Option[String] = None
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -32,6 +34,12 @@ case class Decider(
|
||||
*/
|
||||
object DeciderRepository {
|
||||
|
||||
/**
|
||||
* Владелец по умолчанию. Записи без ownerSteamId (например, из старого
|
||||
* application.conf) считаются принадлежащими этому Steam ID.
|
||||
*/
|
||||
val DefaultOwnerSteamId: String = "76561198137977374"
|
||||
|
||||
private def appConfig: Config = ConfigFactory.load()
|
||||
|
||||
/** Файл, в который админка пишет десайдеров. */
|
||||
@@ -88,7 +96,11 @@ object DeciderRepository {
|
||||
existAdditionalRaces = Try(d.getBoolean("existAdditionalRaces")).getOrElse(false),
|
||||
existTextFieldRaces = Try(d.getBoolean("existTextFieldRaces")).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 =>
|
||||
sb.append(" ").append(quoteKey(d.key)).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')
|
||||
if (d.existAdditionalRaces) sb.append(" existAdditionalRaces = true\n")
|
||||
if (d.existTextFieldRaces) sb.append(" existTextFieldRaces = true\n")
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
package repository
|
||||
|
||||
import java.io.File
|
||||
import java.net.{JarURLConnection, URL}
|
||||
import java.nio.file.StandardCopyOption
|
||||
import scala.collection.JavaConverters._
|
||||
|
||||
import com.typesafe.config.ConfigFactory
|
||||
|
||||
import scala.util.Try
|
||||
|
||||
/** Изображение карты, доступное для выбора в админке. */
|
||||
case class MapImage(name: String, file: String, players: Int)
|
||||
|
||||
/**
|
||||
* Список картинок карт из app/assets/images/maps.
|
||||
* Список картинок карт из каталога, заданного в конфиге (`maps.imagesDir`).
|
||||
*
|
||||
* Картинки читаются из исходников (при запуске через `sbt run`) либо из
|
||||
* classpath (когда приложение собрано и assets упакованы в jar).
|
||||
* Каталог читается при каждом обращении, поэтому загруженные через админку
|
||||
* картинки сразу появляются в галерее без перезапуска приложения.
|
||||
*/
|
||||
object MapImageRepository {
|
||||
|
||||
@@ -23,36 +24,62 @@ object MapImageRepository {
|
||||
private val IgnoredFiles = Set("default.jpg")
|
||||
private val IgnoredPattern = """^img(_\d+)?\.(jpg|jpeg|png|webp)$""".r
|
||||
|
||||
/** Каталоги с картинками: исходники и результат сборки assets. */
|
||||
private val MapDirectories = Seq(
|
||||
"app/assets/images/maps",
|
||||
"target/web/public/main/images/maps",
|
||||
"public/images/maps"
|
||||
)
|
||||
/** Ключ конфига с каталогом картинок карт. */
|
||||
private val ConfigKey = "maps.imagesDir"
|
||||
|
||||
private val ClasspathDirectory = "public/images/maps"
|
||||
/** Значение по умолчанию, если ключ в конфиге отсутствует. */
|
||||
private val DefaultImagesDirectory = "app/assets/images/maps"
|
||||
|
||||
/** Каталог, в который сохраняются загруженные картинки (исходники). */
|
||||
private val SourceUploadDirectory = "app/assets/images/maps"
|
||||
|
||||
/** Каталоги, из которых Play раздаёт assets в dev-режиме. */
|
||||
private val RuntimeUploadDirectory = "target/web/public/main/images/maps"
|
||||
/** Каталог с картинками карт из конфига. */
|
||||
def imagesDirectory: File = {
|
||||
val configured = Try(ConfigFactory.load().getString(ConfigKey)).toOption
|
||||
.map(_.trim)
|
||||
.filter(_.nonEmpty)
|
||||
.getOrElse(DefaultImagesDirectory)
|
||||
new File(configured)
|
||||
}
|
||||
|
||||
/** Максимальный размер загружаемого файла. */
|
||||
private val MaxUploadBytes = 10L * 1024 * 1024
|
||||
|
||||
/** Все карты, отсортированные по количеству игроков и имени. */
|
||||
def all: Seq[MapImage] =
|
||||
loadFileNames()
|
||||
.distinct
|
||||
.map(toMapImage)
|
||||
.sortBy(image => (groupOrder(image.players), image.name.toLowerCase))
|
||||
def all: Seq[MapImage] = {
|
||||
val directory = imagesDirectory
|
||||
if (!directory.isDirectory) Seq.empty
|
||||
else
|
||||
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] = {
|
||||
@@ -62,34 +89,23 @@ object MapImageRepository {
|
||||
else sanitizeFileName(rawFileName) match {
|
||||
case None => Left("Unsupported file (allowed types: jpg, jpeg, png, webp)")
|
||||
case Some(safeName) =>
|
||||
val directories = uploadDirectories()
|
||||
if (directories.isEmpty) Left("Map images directory not found")
|
||||
else {
|
||||
val fileName = uniqueFileName(safeName, directories)
|
||||
Try {
|
||||
directories.foreach { dir =>
|
||||
java.nio.file.Files.copy(
|
||||
source.toPath,
|
||||
new File(dir, fileName).toPath,
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
}
|
||||
}.toEither match {
|
||||
case Right(_) => Right(toMapImage(fileName))
|
||||
case Left(error) => Left(s"Failed to save file: ${error.getMessage}")
|
||||
}
|
||||
val directory = imagesDirectory
|
||||
Try {
|
||||
if (!directory.isDirectory) directory.mkdirs()
|
||||
val fileName = uniqueFileName(safeName, directory)
|
||||
java.nio.file.Files.copy(
|
||||
source.toPath,
|
||||
new File(directory, fileName).toPath,
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
fileName
|
||||
}.toEither match {
|
||||
case Right(fileName) => 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] = {
|
||||
val name = Option(rawFileName)
|
||||
@@ -105,8 +121,8 @@ object MapImageRepository {
|
||||
}
|
||||
|
||||
/** Подбирает свободное имя, добавляя суффикс -1, -2, ... при совпадении. */
|
||||
private def uniqueFileName(fileName: String, directories: Seq[File]): String = {
|
||||
def taken(name: String): Boolean = directories.exists(dir => new File(dir, name).exists())
|
||||
private def uniqueFileName(fileName: String, directory: File): String = {
|
||||
def taken(name: String): Boolean = new File(directory, name).exists()
|
||||
|
||||
if (!taken(fileName)) fileName
|
||||
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 = {
|
||||
val lower = fileName.toLowerCase
|
||||
ImageExtensions.exists(ext => lower.endsWith("." + ext)) &&
|
||||
@@ -138,39 +143,6 @@ object MapImageRepository {
|
||||
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 = {
|
||||
val baseName = fileName.substring(0, fileName.lastIndexOf('.'))
|
||||
MapImage(baseName, fileName, playersFrom(fileName))
|
||||
|
||||
@@ -81,9 +81,9 @@
|
||||
</div>
|
||||
<div class="list-group" id="deciderList">
|
||||
@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>
|
||||
<p class="list-group-item-text decider-key">@d.key • maps: @d.maps.size</p>
|
||||
<p class="list-group-item-text decider-key">@d.key • maps: @d.maps.size • owner: @d.ownerSteamId.getOrElse("-")</p>
|
||||
</a>
|
||||
}
|
||||
@if(deciders.isEmpty) {
|
||||
@@ -110,6 +110,17 @@
|
||||
<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>
|
||||
<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 class="form-group">
|
||||
@@ -218,6 +229,8 @@
|
||||
<script src='@routes.Assets.at("lib/underscore/underscore.js")'></script>
|
||||
<script>
|
||||
var currentDeciderKey = null;
|
||||
// Steam ID текущего пользователя: владелец нового десайдера.
|
||||
var currentSteamId = '@request.session.get("steamId").getOrElse("")';
|
||||
|
||||
// CSRF: все AJAX-запросы отправляем с токеном из meta-тега,
|
||||
// иначе Play отклоняет POST/DELETE с JSON-телом.
|
||||
@@ -266,7 +279,21 @@
|
||||
|
||||
$('#deciderKey').val(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);
|
||||
$('#formOwner').val(decider.ownerSteamId || currentSteamId);
|
||||
$('#formName').val(decider.name);
|
||||
$('#formRules').val(decider.rules);
|
||||
$('#formRaceCount').val(decider.raceCount);
|
||||
@@ -288,11 +315,13 @@
|
||||
// Превью карт: подгружаем список картинок, чтобы найти файлы любого формата.
|
||||
ensureMapImages();
|
||||
|
||||
// Highlight selected in list
|
||||
// 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');
|
||||
if (decider.key) {
|
||||
$('.list-group-item').filter(function() {
|
||||
return $(this).attr('data-key') === decider.key;
|
||||
}).addClass('active');
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
$(document).ready(function() {
|
||||
// Nothing to do on load - user selects from list
|
||||
$('#mapPickerModal').on('hidden.bs.modal', function() {
|
||||
pickerTargetRow = null;
|
||||
});
|
||||
|
||||
$('#btnCopyUrl').on('click', copyDeciderUrl);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -14,6 +14,13 @@ admin {
|
||||
decidersFile = "conf/deciders.conf"
|
||||
}
|
||||
|
||||
# Каталог с картинками карт: из него читается галерея в админке и в него
|
||||
# сохраняются загруженные картинки. Путь может быть абсолютным или
|
||||
# относительным (от рабочего каталога приложения).
|
||||
maps {
|
||||
imagesDir = "app/assets/images/maps"
|
||||
}
|
||||
|
||||
deciders{
|
||||
classic{
|
||||
maps = [["2p_battle_marshes"],
|
||||
|
||||
+1604
File diff suppressed because it is too large
Load Diff
@@ -21,5 +21,9 @@ DELETE /decider/admin/deciders/:key controllers.AdminController.deciderDel
|
||||
|
||||
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
|
||||
GET /assets/*file controllers.Assets.at(path="/public", file)
|
||||
|
||||
Reference in New Issue
Block a user