Decider admin
This commit is contained in:
@@ -4,14 +4,11 @@ import actors.UserActor._
|
|||||||
import akka.actor.{Actor, ActorRef}
|
import akka.actor.{Actor, ActorRef}
|
||||||
import akka.event.LoggingReceive
|
import akka.event.LoggingReceive
|
||||||
import akka.util.Timeout
|
import akka.util.Timeout
|
||||||
import com.typesafe.config.ConfigFactory
|
|
||||||
import com.typesafe.scalalogging.LazyLogging
|
import com.typesafe.scalalogging.LazyLogging
|
||||||
|
import repository.DeciderRepository
|
||||||
|
|
||||||
import java.util
|
|
||||||
import scala.collection.JavaConverters.iterableAsScalaIterableConverter
|
|
||||||
import scala.collection.immutable.HashSet
|
import scala.collection.immutable.HashSet
|
||||||
import scala.concurrent.duration._
|
import scala.concurrent.duration._
|
||||||
import scala.util.Try
|
|
||||||
|
|
||||||
case class LobbyUser(name: String, actorRef: ActorRef)
|
case class LobbyUser(name: String, actorRef: ActorRef)
|
||||||
|
|
||||||
@@ -23,7 +20,6 @@ case class DeciderMap(map: String, description: Option[String] = None, var isBan
|
|||||||
*/
|
*/
|
||||||
class LobbieActor(hostUser: LobbyUser, deciderName: String) extends Actor with LazyLogging {
|
class LobbieActor(hostUser: LobbyUser, deciderName: String) extends Actor with LazyLogging {
|
||||||
|
|
||||||
val config = ConfigFactory.load()
|
|
||||||
logger.info(s"Create lobby... host: ${hostUser.actorRef.path.name}")
|
logger.info(s"Create lobby... host: ${hostUser.actorRef.path.name}")
|
||||||
|
|
||||||
|
|
||||||
@@ -49,11 +45,12 @@ class LobbieActor(hostUser: LobbyUser, deciderName: String) extends Actor with L
|
|||||||
|
|
||||||
|
|
||||||
private val mapsLobby: Set[DeciderMap] = {
|
private val mapsLobby: Set[DeciderMap] = {
|
||||||
val configMaps = config.getList(s"deciders.$deciderName.maps").asScala
|
DeciderRepository.get(deciderName) match {
|
||||||
configMaps.map(e => {
|
case Some(d) =>
|
||||||
val mapConfig = e.unwrapped().asInstanceOf[util.ArrayList[String]].asScala
|
d.maps.map(m => DeciderMap(m.map, m.description)).toSet
|
||||||
DeciderMap(mapConfig.head, mapConfig.tail.headOption)
|
case None =>
|
||||||
}).toSet
|
Set.empty[DeciderMap]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hostUser.actorRef.tell(
|
hostUser.actorRef.tell(
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
@@ -1,52 +1,241 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import actors.UserParentActor.GetAllUsers
|
|
||||||
import actors._
|
|
||||||
import akka.NotUsed
|
|
||||||
import akka.actor._
|
|
||||||
import akka.event.Logging
|
|
||||||
import akka.pattern.ask
|
|
||||||
import akka.stream._
|
|
||||||
import akka.stream.scaladsl._
|
|
||||||
import akka.util.Timeout
|
|
||||||
import com.typesafe.config.ConfigFactory
|
|
||||||
|
|
||||||
import javax.inject._
|
import javax.inject._
|
||||||
import org.reactivestreams.Publisher
|
import com.typesafe.config.ConfigFactory
|
||||||
|
import play.api.i18n.Messages
|
||||||
import play.api.libs.json._
|
import play.api.libs.json._
|
||||||
import play.api.mvc._
|
import play.api.mvc._
|
||||||
|
import repository.DeciderRepository
|
||||||
|
import play.api.libs.ws.WSClient
|
||||||
|
|
||||||
import scala.concurrent.duration._
|
import scala.collection.JavaConverters._
|
||||||
import scala.concurrent.{Await, ExecutionContext, Future}
|
import scala.concurrent.{ExecutionContext, Future}
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class creates the actions and the websocket needed.
|
* Admin panel for managing deciders.
|
||||||
|
* Authentication via Steam OpenID.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class AdminController @Inject()(cc: ControllerComponents, homeController: HomeController) extends AbstractController(cc) {
|
class AdminController @Inject()(
|
||||||
|
cc: ControllerComponents,
|
||||||
|
ws: WSClient
|
||||||
|
) extends AbstractController(cc) with SecuredActions {
|
||||||
|
|
||||||
implicit val actorSystem: ActorSystem = ActorSystem()
|
implicit val ec: ExecutionContext = cc.executionContext
|
||||||
implicit val ec: ExecutionContext = defaultExecutionContext
|
private val logger = org.slf4j.LoggerFactory.getLogger("controllers.AdminController")
|
||||||
implicit val timeout = Timeout(100.millis)
|
|
||||||
|
|
||||||
// Use a direct reference to SLF4J
|
// ---------- Steam OpenID ----------
|
||||||
private val logger = org.slf4j.LoggerFactory.getLogger("controllers.HomeController")
|
|
||||||
|
|
||||||
val userParentActor: ActorRef = homeController.userParentActor
|
private val SteamLoginUrl = "https://steamcommunity.com/openid/login"
|
||||||
|
|
||||||
// Home page that renders template
|
// ---------- Public endpoints ----------
|
||||||
def viewAllUsers() = Action { implicit request =>
|
|
||||||
logger.info(s"Received request from: ${request.remoteAddress}")
|
|
||||||
|
|
||||||
val allUsers = userParentActor ? GetAllUsers
|
def login() = Action { implicit request =>
|
||||||
val res = Await.result(allUsers, Duration.Inf)
|
val realm = request.headers.get("Host").map(h => s"http://$h/").getOrElse("http://localhost:1337/")
|
||||||
Ok(res.toString)
|
val returnTo = controllers.routes.AdminController.steamCallback().absoluteURL()
|
||||||
|
|
||||||
|
val params = Map(
|
||||||
|
"openid.ns" -> "http://specs.openid.net/auth/2.0",
|
||||||
|
"openid.mode" -> "checkid_setup",
|
||||||
|
"openid.return_to" -> returnTo,
|
||||||
|
"openid.realm" -> realm,
|
||||||
|
"openid.identity" -> "http://specs.openid.net/auth/2.0/identifier_select",
|
||||||
|
"openid.claimed_id" -> "http://specs.openid.net/auth/2.0/identifier_select"
|
||||||
|
)
|
||||||
|
|
||||||
|
val query = params.map { case (k, v) => s"${java.net.URLEncoder.encode(k, "UTF-8")}=${java.net.URLEncoder.encode(v, "UTF-8")}" }.mkString("&")
|
||||||
|
Redirect(s"$SteamLoginUrl?$query")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def steamCallback() = Action.async { implicit request =>
|
||||||
|
// Steam присылает параметры в формате OpenID: openid.mode, openid.identity,
|
||||||
|
// openid.claimed_id. Play отдаёт ключи в queryString как есть, с точкой,
|
||||||
|
// поэтому нормализуем имена к виду openid_mode и т.д.
|
||||||
|
val openidParams = request.queryString.map { case (k, v) => k.replace('.', '_') -> v }
|
||||||
|
|
||||||
def reloadConfig()= Action { implicit request =>
|
// Validate Steam OpenID response
|
||||||
ConfigFactory.invalidateCaches()
|
val mode = openidParams.get("openid_mode").flatMap(_.headOption)
|
||||||
logger.info("Config reloaded")
|
val identifier = openidParams.get("openid_identity").flatMap(_.headOption)
|
||||||
|
val assertion = openidParams.get("openid_claimed_id").flatMap(_.headOption)
|
||||||
|
|
||||||
|
(mode, identifier orElse assertion) match {
|
||||||
|
case (Some("id_res"), Some(steamId)) if isSteamId(steamId) =>
|
||||||
|
val steamId64 = parseSteamId64(steamId)
|
||||||
|
val config = ConfigFactory.load()
|
||||||
|
val allowed = Try(config.getStringList("admin.allowedSteamIds").asScala.toSet).getOrElse(Set.empty)
|
||||||
|
if (allowed.isEmpty || allowed.contains(steamId64)) {
|
||||||
|
Future.successful(Redirect("/decider/admin").withSession("steamId" -> steamId64))
|
||||||
|
} else {
|
||||||
|
Future.successful(Redirect("/decider/admin").flashing("error" -> "You are not authorized"))
|
||||||
|
}
|
||||||
|
|
||||||
|
case (Some("id_res"), _) =>
|
||||||
|
Future.successful(Redirect("/decider/admin").flashing("error" -> "Steam authentication failed"))
|
||||||
|
|
||||||
|
case _ =>
|
||||||
|
Future.successful(Redirect("/decider/admin").flashing("error" -> "Invalid OpenID response"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def isSteamId(id: String): Boolean =
|
||||||
|
id.startsWith("https://steamcommunity.com/openid/id/") ||
|
||||||
|
id.startsWith("http://steamcommunity.com/openid/id/")
|
||||||
|
|
||||||
|
private def parseSteamId64(id: String): String =
|
||||||
|
id.replaceAll("""(https?://steamcommunity\.com/openid/id/)""", "")
|
||||||
|
|
||||||
|
// ---------- Admin page ----------
|
||||||
|
|
||||||
|
def index() = Secured { implicit request =>
|
||||||
|
implicit val messages: Messages = messagesApi.preferred(request)
|
||||||
|
Ok(views.html.admin(DeciderRepository.all))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- API: list map images ----------
|
||||||
|
|
||||||
|
def mapImages() = Secured { implicit request =>
|
||||||
|
val images = repository.MapImageRepository.all.map { image =>
|
||||||
|
Json.obj(
|
||||||
|
"name" -> image.name,
|
||||||
|
"file" -> image.file,
|
||||||
|
"players" -> image.players
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Ok(Json.toJson(images))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- API: upload map image ----------
|
||||||
|
|
||||||
|
def mapImageUpload() = Secured(parse.multipartFormData) { implicit request =>
|
||||||
|
request.body.file("file") match {
|
||||||
|
case Some(part) =>
|
||||||
|
repository.MapImageRepository.saveUploaded(part.filename, part.ref.path.toFile) match {
|
||||||
|
case Right(image) =>
|
||||||
|
logger.info(s"Admin ${SecuredActions.steamIdOrUnknown(request)} uploaded map image '${image.file}'")
|
||||||
|
Ok(Json.obj(
|
||||||
|
"ok" -> true,
|
||||||
|
"name" -> image.name,
|
||||||
|
"file" -> image.file,
|
||||||
|
"players" -> image.players
|
||||||
|
))
|
||||||
|
case Left(error) =>
|
||||||
|
BadRequest(Json.obj("error" -> error))
|
||||||
|
}
|
||||||
|
case None =>
|
||||||
|
BadRequest(Json.obj("error" -> "No file uploaded"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 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))
|
||||||
|
))
|
||||||
|
Ok(Json.toJson(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- API: get one decider ----------
|
||||||
|
|
||||||
|
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 None =>
|
||||||
|
NotFound(Json.obj("error" -> s"Decider $key not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- API: save / update decider ----------
|
||||||
|
|
||||||
|
def deciderSave() = SecuredJson { implicit request =>
|
||||||
|
val json = request.body
|
||||||
|
|
||||||
|
(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})"
|
||||||
|
)
|
||||||
|
} 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))
|
||||||
|
|
||||||
|
case Left(error) =>
|
||||||
|
BadRequest(Json.obj("error" -> s"$error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Legacy endpoints (deprecated) ----------
|
||||||
|
|
||||||
|
@deprecated("unused", "1.0")
|
||||||
|
def viewAllUsers() = Action { implicit request =>
|
||||||
|
logger.info("viewAllUsers")
|
||||||
|
Ok("viewAllUsers")
|
||||||
|
}
|
||||||
|
|
||||||
|
@deprecated("unused", "1.0")
|
||||||
|
def reloadConfig() = Action { implicit request =>
|
||||||
Ok("Config reloaded")
|
Ok("Config reloaded")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.typesafe.config.ConfigFactory
|
|||||||
import org.reactivestreams.Publisher
|
import org.reactivestreams.Publisher
|
||||||
import play.api.libs.json._
|
import play.api.libs.json._
|
||||||
import play.api.mvc._
|
import play.api.mvc._
|
||||||
|
import repository.DeciderRepository
|
||||||
|
|
||||||
import scala.concurrent.duration._
|
import scala.concurrent.duration._
|
||||||
import scala.concurrent.{ExecutionContext, Future}
|
import scala.concurrent.{ExecutionContext, Future}
|
||||||
@@ -41,14 +42,15 @@ class HomeController @Inject()(cc: ControllerComponents) extends AbstractControl
|
|||||||
// Home page that renders template
|
// Home page that renders template
|
||||||
def index(deciderName: String) = Action { implicit request =>
|
def index(deciderName: String) = Action { implicit request =>
|
||||||
logger.info(s"Received request from: ${request.remoteAddress}")
|
logger.info(s"Received request from: ${request.remoteAddress}")
|
||||||
val deciderHumanName = config.getString(s"deciders.$deciderName.name")
|
val deciderOpt = DeciderRepository.get(deciderName)
|
||||||
val deciderDescription = config.getString(s"deciders.$deciderName.rules")
|
if (deciderOpt.isEmpty) {
|
||||||
val existAdditionalRaces = Try(config.getBoolean(s"deciders.$deciderName.existAdditionalRaces")).getOrElse(false)
|
NotFound(s"Decider $deciderName not found")
|
||||||
val existTextFieldRaces = Try(config.getBoolean(s"deciders.$deciderName.existTextFieldRaces")).getOrElse(false)
|
} else {
|
||||||
val raceCount = Try(config.getInt(s"deciders.$deciderName.raceCount")).getOrElse(1)
|
val d = deciderOpt.get
|
||||||
val lastmapsSettings = LastMapsSelectConfig(true, true, true, true)
|
val lastmapsSettings = LastMapsSelectConfig(true, true, true, true)
|
||||||
val raceSelect = RaceSelect(true, false)
|
val raceSelect = RaceSelect(true, false)
|
||||||
Ok(views.html.index(deciderName, deciderHumanName, raceCount, deciderDescription, raceSelect, lastmapsSettings, existAdditionalRaces, existTextFieldRaces))
|
Ok(views.html.index(deciderName, d.name, d.raceCount, d.rules, raceSelect, lastmapsSettings, d.existAdditionalRaces, d.existTextFieldRaces))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import play.api.libs.json.{JsValue, Json}
|
||||||
|
import play.api.libs.typedmap.TypedKey
|
||||||
|
import play.api.mvc._
|
||||||
|
|
||||||
|
import scala.concurrent.{ExecutionContext, Future}
|
||||||
|
|
||||||
|
object SecuredActions {
|
||||||
|
|
||||||
|
private val SteamIdAttr: TypedKey[String] = TypedKey("steamId")
|
||||||
|
|
||||||
|
/** Steam ID текущего авторизованного пользователя (если он есть в запросе). */
|
||||||
|
def steamId[A](request: Request[A]): Option[String] = request.attrs.get(SteamIdAttr)
|
||||||
|
|
||||||
|
/** Steam ID текущего пользователя или "unknown", если его нет в запросе. */
|
||||||
|
def steamIdOrUnknown[A](request: Request[A]): String = steamId(request).getOrElse("unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple session-based authentication via Steam ID.
|
||||||
|
* Used to protect admin panel routes.
|
||||||
|
*/
|
||||||
|
trait SecuredActions {
|
||||||
|
|
||||||
|
self: AbstractController =>
|
||||||
|
|
||||||
|
private val SteamIdAttr: TypedKey[String] = SecuredActions.SteamIdAttr
|
||||||
|
|
||||||
|
/** Wrapper that requires the user to be logged in with Steam. */
|
||||||
|
def Secured: ActionBuilder[Request, AnyContent] =
|
||||||
|
securedWith(controllerComponents.parsers.defaultBodyParser, apiStyle = false)
|
||||||
|
|
||||||
|
/** Same as [[Secured]], but parses the request body as JSON. */
|
||||||
|
def SecuredJson: ActionBuilder[Request, JsValue] =
|
||||||
|
securedWith(controllerComponents.parsers.json, apiStyle = true)
|
||||||
|
|
||||||
|
/** Same as [[Secured]], but answers AJAX calls with 401 JSON instead of a redirect. */
|
||||||
|
def SecuredApi: ActionBuilder[Request, AnyContent] =
|
||||||
|
securedWith(controllerComponents.parsers.defaultBodyParser, apiStyle = true)
|
||||||
|
|
||||||
|
private def securedWith[A](bodyParser: BodyParser[A], apiStyle: Boolean): ActionBuilder[Request, A] =
|
||||||
|
new ActionBuilder[Request, A] {
|
||||||
|
|
||||||
|
override def parser: BodyParser[A] = bodyParser
|
||||||
|
|
||||||
|
override def executionContext: ExecutionContext = controllerComponents.executionContext
|
||||||
|
|
||||||
|
override def invokeBlock[B](request: Request[B], block: Request[B] => Future[Result]): Future[Result] =
|
||||||
|
request.session.get("steamId") match {
|
||||||
|
case Some(steamId) =>
|
||||||
|
block(request.addAttr(SteamIdAttr, steamId))
|
||||||
|
case None if apiStyle =>
|
||||||
|
Future.successful(Unauthorized(Json.obj("error" -> "Please log in with Steam")))
|
||||||
|
case None =>
|
||||||
|
Future.successful(Redirect("/decider/admin/login").flashing("error" -> "Please log in with Steam"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import com.typesafe.config.{Config, ConfigFactory}
|
||||||
|
|
||||||
|
import java.io.{File, PrintWriter}
|
||||||
|
import scala.collection.JavaConverters._
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
/** Описание одной карты в маппуле десайдера. */
|
||||||
|
case class DeciderMapConfig(map: String, description: Option[String] = None)
|
||||||
|
|
||||||
|
/** Полное описание десайдера (турнира). */
|
||||||
|
case class Decider(
|
||||||
|
key: String,
|
||||||
|
name: String,
|
||||||
|
rules: String,
|
||||||
|
raceCount: Int,
|
||||||
|
existAdditionalRaces: Boolean,
|
||||||
|
existTextFieldRaces: Boolean,
|
||||||
|
description: Boolean,
|
||||||
|
maps: List[DeciderMapConfig]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чтение и запись десайдеров.
|
||||||
|
*
|
||||||
|
* Десайдеры хранятся во внешнем HOCON-файле (admin.decidersFile, по умолчанию
|
||||||
|
* "conf/deciders.conf"). Пока этот файл не существует, десайдеры читаются из
|
||||||
|
* application.conf, поэтому приложение работает "из коробки". Как только админ
|
||||||
|
* что-то сохраняет через админку, внешний файл становится единственным источником
|
||||||
|
* правды.
|
||||||
|
*/
|
||||||
|
object DeciderRepository {
|
||||||
|
|
||||||
|
private def appConfig: Config = ConfigFactory.load()
|
||||||
|
|
||||||
|
/** Файл, в который админка пишет десайдеров. */
|
||||||
|
def file: File = {
|
||||||
|
val path = Try(appConfig.getString("admin.decidersFile")).getOrElse("conf/deciders.conf")
|
||||||
|
new File(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Десайдеры как объект Config (содержимое блока `deciders { ... }`). */
|
||||||
|
def decidersConfig: Config = {
|
||||||
|
val f = file
|
||||||
|
if (f.isFile) {
|
||||||
|
Try(ConfigFactory.parseFile(f)).toOption
|
||||||
|
.filter(_.hasPath("deciders"))
|
||||||
|
.map(_.getConfig("deciders"))
|
||||||
|
.getOrElse(fallback)
|
||||||
|
} else {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переинициализировать кеш конфигуратора (вызывать после сохранения). */
|
||||||
|
def reload(): Unit = {
|
||||||
|
ConfigFactory.invalidateCaches()
|
||||||
|
}
|
||||||
|
|
||||||
|
private def fallback: Config =
|
||||||
|
if (appConfig.hasPath("deciders")) appConfig.getConfig("deciders") else ConfigFactory.empty()
|
||||||
|
|
||||||
|
/** Ключи всех десайдеров (отсортированные). */
|
||||||
|
def keys: Seq[String] = decidersConfig.root().keySet().asScala.toSeq.sorted
|
||||||
|
|
||||||
|
/** Один десайдер по ключу. */
|
||||||
|
def get(key: String): Option[Decider] = {
|
||||||
|
val c = decidersConfig
|
||||||
|
if (!c.hasPath(key)) None
|
||||||
|
else {
|
||||||
|
val d = c.getConfig(key)
|
||||||
|
val maps = d.getList("maps").asScala.toList.map { value =>
|
||||||
|
value.unwrapped() match {
|
||||||
|
case list: java.util.List[_] =>
|
||||||
|
val parts = list.asScala.map(_.toString).toList
|
||||||
|
DeciderMapConfig(parts.head, parts.lift(1).filter(_.nonEmpty))
|
||||||
|
case other =>
|
||||||
|
// Некоторые десайдеры заданы просто списком строк без описаний.
|
||||||
|
DeciderMapConfig(other.toString, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Decider(
|
||||||
|
key = key,
|
||||||
|
name = Try(d.getString("name")).getOrElse(key),
|
||||||
|
rules = Try(d.getString("rules")).getOrElse(""),
|
||||||
|
raceCount = Try(d.getInt("raceCount")).getOrElse(1),
|
||||||
|
existAdditionalRaces = Try(d.getBoolean("existAdditionalRaces")).getOrElse(false),
|
||||||
|
existTextFieldRaces = Try(d.getBoolean("existTextFieldRaces")).getOrElse(false),
|
||||||
|
description = Try(d.getBoolean("description")).getOrElse(false),
|
||||||
|
maps = maps
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все десайдеры. */
|
||||||
|
def all: Seq[Decider] = keys.flatMap(get)
|
||||||
|
|
||||||
|
/** Создаёт или обновляет десайдера и перезаписывает весь файл десайдеров. */
|
||||||
|
def save(decider: Decider): Unit = {
|
||||||
|
val updated = (all.filterNot(_.key == decider.key) :+ decider).sortBy(_.key)
|
||||||
|
write(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удаляет десайдера. Возвращает true, если десайдер существовал. */
|
||||||
|
def delete(key: String): Boolean = {
|
||||||
|
val current = all
|
||||||
|
if (current.exists(_.key == key)) {
|
||||||
|
write(current.filterNot(_.key == key))
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def write(deciders: Seq[Decider]): Unit = {
|
||||||
|
val sb = new StringBuilder
|
||||||
|
sb.append("# Конфигурация десайдеров.\n")
|
||||||
|
sb.append("# Файл управляется админкой (авторизация через Steam).\n")
|
||||||
|
sb.append("# Ручные правки могут быть перезаписаны при следующем сохранении.\n\n")
|
||||||
|
sb.append("deciders {\n")
|
||||||
|
deciders.foreach { d =>
|
||||||
|
sb.append(" ").append(quoteKey(d.key)).append(" {\n")
|
||||||
|
sb.append(" name = ").append(quote(d.name)).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")
|
||||||
|
if (d.description) sb.append(" description = true\n")
|
||||||
|
if (d.maps.isEmpty) {
|
||||||
|
sb.append(" maps = []\n")
|
||||||
|
} else {
|
||||||
|
sb.append(" maps = [\n")
|
||||||
|
d.maps.zipWithIndex.foreach { case (m, i) =>
|
||||||
|
sb.append(" ").append(renderMap(m))
|
||||||
|
if (i < d.maps.size - 1) sb.append(',')
|
||||||
|
sb.append('\n')
|
||||||
|
}
|
||||||
|
sb.append(" ]\n")
|
||||||
|
}
|
||||||
|
sb.append(" rules = ").append(quote(d.rules)).append('\n')
|
||||||
|
sb.append(" }\n")
|
||||||
|
}
|
||||||
|
sb.append("}\n")
|
||||||
|
|
||||||
|
val f = file
|
||||||
|
Option(f.getParentFile).foreach(_.mkdirs())
|
||||||
|
val writer = new PrintWriter(f, "UTF-8")
|
||||||
|
try writer.write(sb.toString) finally writer.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private def renderMap(m: DeciderMapConfig): String =
|
||||||
|
m.description.filter(_.nonEmpty) match {
|
||||||
|
case Some(desc) => s"[${quote(m.map)}, ${quote(desc)}]"
|
||||||
|
case None => s"[${quote(m.map)}]"
|
||||||
|
}
|
||||||
|
|
||||||
|
private def quoteKey(key: String): String =
|
||||||
|
if (key.matches("[A-Za-z0-9_-]+") && !key.forall(_.isDigit)) key else quote(key)
|
||||||
|
|
||||||
|
/** Рендерит строку как строку в формате JSON/HOCON. */
|
||||||
|
private def quote(value: String): String = {
|
||||||
|
val sb = new StringBuilder("\"")
|
||||||
|
value.foreach {
|
||||||
|
case '"' => sb.append("\\\"")
|
||||||
|
case '\\' => sb.append("\\\\")
|
||||||
|
case '\n' => sb.append("\\n")
|
||||||
|
case '\r' => sb.append("\\r")
|
||||||
|
case '\t' => sb.append("\\t")
|
||||||
|
// Литерал из обратного слэша и буквы u ломает компилятор Scala:
|
||||||
|
// такая последовательность обрабатывается как Unicode-escape ещё до лексера.
|
||||||
|
// Поэтому символы добавляем по отдельности, без escape-последовательности в исходнике.
|
||||||
|
case c if c < ' ' =>
|
||||||
|
sb.append(0x5c.toChar).append('u').append("%04x".format(c.toInt))
|
||||||
|
case c => sb.append(c)
|
||||||
|
}
|
||||||
|
sb.append('"')
|
||||||
|
sb.toString
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.net.{JarURLConnection, URL}
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import scala.collection.JavaConverters._
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
/** Изображение карты, доступное для выбора в админке. */
|
||||||
|
case class MapImage(name: String, file: String, players: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список картинок карт из app/assets/images/maps.
|
||||||
|
*
|
||||||
|
* Картинки читаются из исходников (при запуске через `sbt run`) либо из
|
||||||
|
* classpath (когда приложение собрано и assets упакованы в jar).
|
||||||
|
*/
|
||||||
|
object MapImageRepository {
|
||||||
|
|
||||||
|
private val ImageExtensions = Set("jpg", "jpeg", "png", "webp")
|
||||||
|
|
||||||
|
/** Служебные картинки, которые не являются картами. */
|
||||||
|
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 ClasspathDirectory = "public/images/maps"
|
||||||
|
|
||||||
|
/** Каталог, в который сохраняются загруженные картинки (исходники). */
|
||||||
|
private val SourceUploadDirectory = "app/assets/images/maps"
|
||||||
|
|
||||||
|
/** Каталоги, из которых Play раздаёт assets в dev-режиме. */
|
||||||
|
private val RuntimeUploadDirectory = "target/web/public/main/images/maps"
|
||||||
|
|
||||||
|
/** Максимальный размер загружаемого файла. */
|
||||||
|
private val MaxUploadBytes = 10L * 1024 * 1024
|
||||||
|
|
||||||
|
/** Все карты, отсортированные по количеству игроков и имени. */
|
||||||
|
def all: Seq[MapImage] =
|
||||||
|
loadFileNames()
|
||||||
|
.distinct
|
||||||
|
.map(toMapImage)
|
||||||
|
.sortBy(image => (groupOrder(image.players), image.name.toLowerCase))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохраняет загруженную картинку карты и возвращает её описание.
|
||||||
|
*
|
||||||
|
* Файл пишется и в исходники (app/assets), и в каталог, который раздаётся
|
||||||
|
* в dev-режиме (target/web/public/main), чтобы картинка сразу появилась
|
||||||
|
* в галерее без перезапуска приложения.
|
||||||
|
*/
|
||||||
|
def saveUploaded(rawFileName: String, source: File): Either[String, MapImage] = {
|
||||||
|
if (!source.isFile) Left("Uploaded file not found")
|
||||||
|
else if (source.length() <= 0) Left("Uploaded file is empty")
|
||||||
|
else if (source.length() > MaxUploadBytes) Left("File is too large (max 10 MB)")
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Каталоги для записи: исходники создаются при необходимости, остальные — только существующие. */
|
||||||
|
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)
|
||||||
|
.map(_.trim)
|
||||||
|
.map(new File(_).getName)
|
||||||
|
.getOrElse("")
|
||||||
|
val safe = name.map { c =>
|
||||||
|
if (c.isLetterOrDigit || c == '_' || c == '-' || c == '.' || c == ' ' ||
|
||||||
|
c == '(' || c == ')' || c == '[' || c == ']') c
|
||||||
|
else '_'
|
||||||
|
}.mkString.trim
|
||||||
|
if (safe.nonEmpty && isMapImage(safe)) Some(safe) else None
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подбирает свободное имя, добавляя суффикс -1, -2, ... при совпадении. */
|
||||||
|
private def uniqueFileName(fileName: String, directories: Seq[File]): String = {
|
||||||
|
def taken(name: String): Boolean = directories.exists(dir => new File(dir, name).exists())
|
||||||
|
|
||||||
|
if (!taken(fileName)) fileName
|
||||||
|
else {
|
||||||
|
val dot = fileName.lastIndexOf('.')
|
||||||
|
val base = fileName.substring(0, dot)
|
||||||
|
val ext = fileName.substring(dot)
|
||||||
|
Iterator.from(1)
|
||||||
|
.map(i => s"$base-$i$ext")
|
||||||
|
.find(name => !taken(name))
|
||||||
|
.getOrElse(fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) &&
|
||||||
|
!IgnoredFiles.contains(lower) &&
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PlayersPattern = """(?i)(?:^|\])\s*(\d+)p""".r
|
||||||
|
|
||||||
|
/** Количество игроков из префикса имени: 2p -> 2, 4p -> 4 и т.д. */
|
||||||
|
private def playersFrom(fileName: String): Int =
|
||||||
|
PlayersPattern.findFirstMatchIn(fileName).map(_.group(1).toInt).getOrElse(0)
|
||||||
|
|
||||||
|
/** Порядок групп: 1x1, 2x2, 3x3, 4x4, остальные. */
|
||||||
|
private def groupOrder(players: Int): Int = players match {
|
||||||
|
case 2 | 4 | 6 | 8 => players
|
||||||
|
case _ => 99
|
||||||
|
}
|
||||||
|
}
|
||||||
+652
-2
@@ -1,6 +1,656 @@
|
|||||||
@()(implicit r: Request[_])
|
@(deciders: Seq[repository.Decider])(implicit request: Request[_], messages: Messages)
|
||||||
<!DOCTYPE html>
|
<!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 • 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">×</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">×</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">✕</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>
|
</html>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="clear: both"></div>
|
<div style="clear: both"></div>
|
||||||
<div><i><b>Правила турнира "@deciderHumanName"</b><br/>
|
<div><i><b>Правила турнира "@deciderHumanName"</b><br/>
|
||||||
<p>@rules</p>
|
<p style="white-space: pre-wrap">@rules</p>
|
||||||
<br>
|
<br>
|
||||||
</i>
|
</i>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version := "1.0-SNAPSHOT"
|
|||||||
|
|
||||||
lazy val root = (project in file(".")).enablePlugins(PlayScala)
|
lazy val root = (project in file(".")).enablePlugins(PlayScala)
|
||||||
|
|
||||||
scalaVersion := "2.12.8"
|
scalaVersion := "2.12.20"
|
||||||
|
|
||||||
cancelable in Global := true
|
cancelable in Global := true
|
||||||
|
|
||||||
@@ -23,8 +23,9 @@ libraryDependencies += "org.webjars" % "backbonejs" % "1.3.3"
|
|||||||
libraryDependencies += guice
|
libraryDependencies += guice
|
||||||
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2"
|
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2"
|
||||||
|
|
||||||
libraryDependencies += "com.typesafe.play" %% "play" % "2.8.2"
|
// Версии play/play-json берутся из sbt-plugin (2.8.21).
|
||||||
libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.1"
|
// Раньше здесь были явные версии 2.8.2/2.9.1, из-за чего подтягивался Guice 4.2.3
|
||||||
|
// с cglib, который не работает на Java 17+ (InaccessibleObjectException).
|
||||||
|
|
||||||
lazy val akkaVersion = "2.4.18"
|
lazy val akkaVersion = "2.4.18"
|
||||||
libraryDependencies += "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test
|
libraryDependencies += "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test
|
||||||
|
|||||||
+17
-10
@@ -7,6 +7,13 @@ play.filters.hosts {
|
|||||||
play.http.secret.key="QCY?tAnfk?aZ?iwrNwnxIlR6CTf:123123Latabg@5241AB`R5W:1uDFN];Ik@n"
|
play.http.secret.key="QCY?tAnfk?aZ?iwrNwnxIlR6CTf:123123Latabg@5241AB`R5W:1uDFN];Ik@n"
|
||||||
play.server.http.port = 1337
|
play.server.http.port = 1337
|
||||||
|
|
||||||
|
admin {
|
||||||
|
steamApiKey = ""
|
||||||
|
steamRedirectUrl = "http://localhost:1337/decider/admin/steam/callback"
|
||||||
|
allowedSteamIds = []
|
||||||
|
decidersFile = "conf/deciders.conf"
|
||||||
|
}
|
||||||
|
|
||||||
deciders{
|
deciders{
|
||||||
classic{
|
classic{
|
||||||
maps = [["2p_battle_marshes"],
|
maps = [["2p_battle_marshes"],
|
||||||
@@ -42,19 +49,19 @@ deciders{
|
|||||||
}
|
}
|
||||||
montanaz0r{
|
montanaz0r{
|
||||||
maps = [["2p_battle_marshes"],
|
maps = [["2p_battle_marshes"],
|
||||||
|
["2p_blood_river"],
|
||||||
["2p_fallen_city"],
|
["2p_fallen_city"],
|
||||||
["2p_fata_morgana"],
|
|
||||||
["2p_quests_triumph"],
|
|
||||||
["2p_meeting_of_minds"],
|
|
||||||
["2p_shrine_of_excellion"],
|
|
||||||
["2p_titan_fall"],
|
|
||||||
["2p_tranquilitys_end"],
|
["2p_tranquilitys_end"],
|
||||||
["2p_emerald_river"],
|
["2p_shrine_of_excellion"],
|
||||||
|
["2p_quests_triumph"],
|
||||||
["2p_outer_reaches"],
|
["2p_outer_reaches"],
|
||||||
["2p_blood_river"]]
|
["2p_fata_morgana"],
|
||||||
name = "VOX of War tournament"
|
["2p_titan_fall"],
|
||||||
raceCount = 1
|
["2p_meeting_of_minds"],
|
||||||
rules = """There are 11 maps in total, and each player will ban 3 maps. This means that at the end of the draft, 5 maps will remain."""
|
["2p_deadly_fun_archeology"]]
|
||||||
|
name = "-"
|
||||||
|
raceCount = 2
|
||||||
|
rules = """There are 11 maps in total. The veto process varies depending on the round: cross-pick continues until 1 map remains for Bo1, 3 maps remain for Bo3, or 5 maps remain for Bo5."""
|
||||||
}
|
}
|
||||||
mistrzostwa{
|
mistrzostwa{
|
||||||
maps = [["2p_battle_marshes"],
|
maps = [["2p_battle_marshes"],
|
||||||
|
|||||||
+17
-4
@@ -2,11 +2,24 @@
|
|||||||
# This file defines all application routes (Higher priority routes first)
|
# This file defines all application routes (Higher priority routes first)
|
||||||
# ~~~~
|
# ~~~~
|
||||||
|
|
||||||
GET /decider/:name controllers.HomeController.index(name)
|
GET /ws controllers.HomeController.ws
|
||||||
|
|
||||||
GET /ws controllers.HomeController.ws
|
# Admin panel
|
||||||
|
# ВАЖНО: админские маршруты должны идти ДО /decider/:name,
|
||||||
|
# иначе /decider/admin перехватывается как десайдер с именем "admin".
|
||||||
|
GET /decider/admin controllers.AdminController.index()
|
||||||
|
GET /decider/admin/login controllers.AdminController.login()
|
||||||
|
GET /decider/admin/steam/callback controllers.AdminController.steamCallback()
|
||||||
|
|
||||||
GET /admin/users controllers.AdminController.viewAllUsers()
|
# Admin API
|
||||||
|
GET /decider/admin/maps controllers.AdminController.mapImages()
|
||||||
|
POST /decider/admin/maps controllers.AdminController.mapImageUpload()
|
||||||
|
GET /decider/admin/deciders controllers.AdminController.decidersList()
|
||||||
|
GET /decider/admin/deciders/:key controllers.AdminController.deciderGet(key)
|
||||||
|
POST /decider/admin/deciders controllers.AdminController.deciderSave()
|
||||||
|
DELETE /decider/admin/deciders/:key controllers.AdminController.deciderDelete(key)
|
||||||
|
|
||||||
|
GET /decider/:name controllers.HomeController.index(name)
|
||||||
|
|
||||||
# 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)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
sbt.version=1.2.8
|
sbt.version=1.9.8
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,8 @@
|
|||||||
// Use the Play sbt plugin for Play projects
|
// Use the Play sbt plugin for Play projects
|
||||||
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.7.0")
|
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.21")
|
||||||
|
|
||||||
addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.1.2")
|
addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.1.2")
|
||||||
|
|
||||||
|
// Ignore scala-xml binary incompatibility between 1.x (sbt-native-packager, twirl)
|
||||||
|
// and 2.x (scala-compiler) – they are compatible enough for the build to work
|
||||||
|
libraryDependencySchemes += "org.scala-lang.modules" %% "scala-xml" % "always"
|
||||||
|
|||||||
Reference in New Issue
Block a user