Registration and fuzzy search in progress

This commit is contained in:
Claude Brisson
2023-12-15 13:45:23 +01:00
parent 519eca8af3
commit ea44f6068e
26 changed files with 708 additions and 102 deletions

View File

@@ -90,7 +90,7 @@ class ApiServlet : HttpServlet() {
"team" -> TeamHandler "team" -> TeamHandler
else -> ApiHandler.badRequest("unknown sub-entity: $subEntity") else -> ApiHandler.badRequest("unknown sub-entity: $subEntity")
} }
"player" -> PlayerHandler // "player" -> PlayerHandler
else -> ApiHandler.badRequest("unknown entity: $entity") else -> ApiHandler.badRequest("unknown entity: $entity")
} }

View File

@@ -15,9 +15,11 @@ fun Logger.logRequest(req: HttpServletRequest, logHeaders: Boolean = false) {
.append(req.localName) .append(req.localName)
val port = req.localPort val port = req.localPort
if (port != 80) builder.append(':').append(port) if (port != 80) builder.append(':').append(port)
/*
if (!req.contextPath.isEmpty()) { if (!req.contextPath.isEmpty()) {
builder.append(req.contextPath) builder.append(req.contextPath)
} }
*/
builder.append(req.requestURI) builder.append(req.requestURI)
if (req.method == "GET") { if (req.method == "GET") {
val qs = req.queryString val qs = req.queryString

View File

@@ -17,6 +17,7 @@
<url>TODO</url> <url>TODO</url>
<properties> <properties>
<pac4j.version>5.7.1</pac4j.version> <pac4j.version>5.7.1</pac4j.version>
<lucene.version>9.9.0</lucene.version>
</properties> </properties>
<build> <build>
<defaultGoal>package</defaultGoal> <defaultGoal>package</defaultGoal>
@@ -247,6 +248,22 @@
<artifactId>velocity-engine-core</artifactId> <artifactId>velocity-engine-core</artifactId>
<version>2.4-SNAPSHOT</version> <version>2.4-SNAPSHOT</version>
</dependency> </dependency>
<!-- indexing -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>${lucene.version}</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analysis-common</artifactId>
<version>${lucene.version}</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>${lucene.version}</version>
</dependency>
<!-- tests --> <!-- tests -->
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>

View File

@@ -17,7 +17,9 @@ object EGFRatingsHandler: RatingsHandler(RatingsManager.Ratings.EGF) {
val pairs = groups.map { val pairs = groups.map {
Pair(it, match.groups[it]?.value) Pair(it, match.groups[it]?.value)
}.toTypedArray() }.toTypedArray()
Json.Object(*pairs) Json.MutableObject(*pairs).also {
it["origin"] = "EGF"
}
} }
} }
} }

View File

@@ -11,16 +11,18 @@ import java.nio.charset.StandardCharsets
object FFGRatingsHandler: RatingsHandler(RatingsManager.Ratings.FFG) { object FFGRatingsHandler: RatingsHandler(RatingsManager.Ratings.FFG) {
override val defaultURL = URL("https://ffg.jeudego.org/echelle/echtxt/ech_ffg_V3.txt") override val defaultURL = URL("https://ffg.jeudego.org/echelle/echtxt/ech_ffg_V3.txt")
override fun parsePayload(payload: String): Json.Array { override fun parsePayload(payload: String): Json.Array {
return payload.lines().mapNotNullTo(Json.MutableArray()) { return payload.lines().mapNotNullTo(Json.MutableArray()) { line ->
val match = linePattern.matchEntire(it) val match = linePattern.matchEntire(line)
if (match == null) { if (match == null) {
logger.error("could not parse line: $it") logger.error("could not parse line: $line")
null null
} else { } else {
val pairs = groups.map { val pairs = groups.map {
Pair(it, match.groups[it]?.value) Pair(it, match.groups[it]?.value)
}.toTypedArray() }.toTypedArray()
Json.Object(*pairs) Json.MutableObject(*pairs).also {
it["origin"] = "FFG"
}
} }
} }
} }

View File

@@ -0,0 +1,101 @@
package org.jeudego.pairgoth.ratings
import com.republicate.kson.Json
import org.apache.lucene.analysis.LowerCaseFilter
import org.apache.lucene.analysis.standard.StandardAnalyzer
import org.apache.lucene.document.Document
import org.apache.lucene.document.Field
import org.apache.lucene.document.StoredField
import org.apache.lucene.document.StringField
import org.apache.lucene.document.TextField
import org.apache.lucene.index.DirectoryReader
import org.apache.lucene.index.IndexWriter
import org.apache.lucene.index.IndexWriterConfig
import org.apache.lucene.index.Term
import org.apache.lucene.queryparser.complexPhrase.ComplexPhraseQueryParser
import org.apache.lucene.search.BooleanClause
import org.apache.lucene.search.BooleanQuery
import org.apache.lucene.search.FuzzyQuery
import org.apache.lucene.search.IndexSearcher
import org.apache.lucene.search.TermQuery
import org.apache.lucene.store.ByteBuffersDirectory
import org.apache.lucene.store.Directory
import org.apache.lucene.store.NoLockFactory
import org.slf4j.LoggerFactory
import java.util.*
class PlayerIndex {
companion object {
val ID = "id"
val ORIGIN = "origin"
val NAME = "name"
val FIRSTNAME = "firstname"
val TEXT = "text"
val MAX_HITS = 100
val logger = LoggerFactory.getLogger("index")
val queryParser = ComplexPhraseQueryParser(TEXT, StandardAnalyzer())
}
private final val directory: Directory = ByteBuffersDirectory(NoLockFactory.INSTANCE)
private val reader by lazy { DirectoryReader.open(directory) }
private val searcher by lazy { IndexSearcher(reader) }
// helper functions
fun Json.Object.field(key: String) = getString(key) ?: throw Error("missing $key")
fun Json.Object.nullableField(key: String) = getString(key) ?: ""
fun build(players: Json.Array) {
logger.info("indexing players")
var count = 0L
IndexWriter(directory, IndexWriterConfig(StandardAnalyzer()).apply {
setOpenMode(IndexWriterConfig.OpenMode.CREATE)
}).use { writer ->
players.forEachIndexed { i, p ->
val player = p as Json.Object
val origin = p.getString(ORIGIN) ?: throw Error("unknown origin")
val text = player.field(NAME)
val doc = Document()
doc.add(StoredField(ID, i));
doc.add(StringField(ORIGIN, player.field(ORIGIN), Field.Store.NO))
doc.add(TextField(TEXT, "${player.field(NAME)} ${player.nullableField(FIRSTNAME)}", Field.Store.NO))
writer.addDocument(doc);
++count
}
}
logger.info("indexed $count players")
}
fun match(needle: String, origins: Int): List<Int> {
// val fuzzy = FuzzyQuery(Term(TEXT, needle))
val terms = needle.split(Regex("[ -_']+"))
.filter { !it.isEmpty() }
.map { "$it~" }
.joinToString(" ")
val fuzzy = queryParser.parse(terms)
val activeMask = RatingsManager.activeMask()
val query = when (origins.countOneBits()) {
0 -> return emptyList()
1 -> {
val filter = TermQuery(Term(ORIGIN, RatingsManager.Ratings.codeOf(origins)))
BooleanQuery.Builder()
.add(fuzzy, BooleanClause.Occur.SHOULD)
.add(filter, BooleanClause.Occur.MUST)
.build()
}
2 -> {
if (activeMask.countOneBits() > 2) {
val filter =
TermQuery(Term(ORIGIN, RatingsManager.Ratings.codeOf((origins xor activeMask) and activeMask)))
BooleanQuery.Builder()
.add(fuzzy, BooleanClause.Occur.SHOULD)
.add(filter, BooleanClause.Occur.MUST_NOT)
.build()
} else fuzzy
}
3 -> fuzzy
else -> throw Error("wrong origins mask")
}
val docs = searcher.search(query, MAX_HITS)
return docs.scoreDocs.map { searcher.doc(it.doc).getField(ID).numericValue().toInt() }.toList()
}
}

View File

@@ -19,13 +19,14 @@ abstract class RatingsHandler(val origin: RatingsManager.Ratings) {
open val active = true open val active = true
val cacheFile = RatingsManager.path.resolve("${origin.name}.json").toFile() val cacheFile = RatingsManager.path.resolve("${origin.name}.json").toFile()
lateinit var players: Json.Array lateinit var players: Json.Array
private var updated = false
val url: URL by lazy { val url: URL by lazy {
WebappManager.getProperty("ratings.${origin.name.lowercase(Locale.ROOT)}")?.let { URL(it) } ?: defaultURL WebappManager.getProperty("ratings.${origin.name.lowercase(Locale.ROOT)}")?.let { URL(it) } ?: defaultURL
} }
fun updateIfNeeded() { fun updateIfNeeded(): Boolean {
if (Date().time - cacheFile.lastModified() > delay) { return if (Date().time - cacheFile.lastModified() > delay) {
RatingsManager.logger.info("Updating $origin cache from $url") RatingsManager.logger.info("Updating $origin cache from $url")
val payload = fetchPayload() val payload = fetchPayload()
players = parsePayload(payload).also { players = parsePayload(payload).also {
@@ -34,13 +35,17 @@ abstract class RatingsHandler(val origin: RatingsManager.Ratings) {
out.println(cachePayload) out.println(cachePayload)
} }
} }
true
} else if (!this::players.isInitialized) { } else if (!this::players.isInitialized) {
players = Json.parse(cacheFile.readText())?.asArray() ?: Json.Array() players = Json.parse(cacheFile.readText())?.asArray() ?: Json.Array()
true
} else {
false
} }
} }
fun fetchPlayers(): Json.Array { fun fetchPlayers(): Json.Array {
updateIfNeeded() updated = updateIfNeeded()
return players return players
} }
@@ -56,6 +61,7 @@ abstract class RatingsHandler(val origin: RatingsManager.Ratings) {
} }
} }
open fun defaultCharset() = StandardCharsets.UTF_8 open fun defaultCharset() = StandardCharsets.UTF_8
fun updated() = updated
abstract fun parsePayload(payload: String): Json.Array abstract fun parsePayload(payload: String): Json.Array
val logger = LoggerFactory.getLogger(origin.name) val logger = LoggerFactory.getLogger(origin.name)
val atom = "[-._`'a-zA-ZÀ-ÿ]" val atom = "[-._`'a-zA-ZÀ-ÿ]"

View File

@@ -3,15 +3,25 @@ package org.jeudego.pairgoth.ratings
import com.republicate.kson.Json import com.republicate.kson.Json
import org.jeudego.pairgoth.web.WebappManager import org.jeudego.pairgoth.web.WebappManager
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.lang.Exception
import java.nio.file.Path import java.nio.file.Path
import java.util.* import java.util.*
import java.util.concurrent.locks.ReadWriteLock
import java.util.concurrent.locks.ReentrantReadWriteLock
object RatingsManager: Runnable { object RatingsManager: Runnable {
enum class Ratings { enum class Ratings(val flag: Int) {
AGA, AGA(1),
EGF, EGF(2),
FFG FFG(4);
companion object {
fun valueOf(mask: Int): Ratings {
if (mask.countOneBits() != 1) throw Error("wrong use")
return values().filter { it.flag == mask }.firstOrNull() ?: throw Error("wrong mask")
}
fun codeOf(mask: Int) = valueOf(mask).name.lowercase(Locale.ROOT)
}
} }
val ratingsHandlers by lazy { val ratingsHandlers by lazy {
@@ -22,17 +32,35 @@ object RatingsManager: Runnable {
); );
} }
fun activeMask() = ratingsHandlers.entries.filter { it.value.active }.map { it.key.flag }.reduce { a,b -> a or b }
val timer = Timer() val timer = Timer()
lateinit var players: Json.MutableArray lateinit var players: Json.MutableArray
val updateLock: ReadWriteLock = ReentrantReadWriteLock()
override fun run() { override fun run() {
logger.info("launching ratings manager") logger.info("launching ratings manager")
timer.scheduleAtFixedRate(Task, 0L, 3600000L) timer.scheduleAtFixedRate(Task, 0L, 3600000L)
} }
object Task: TimerTask() { object Task: TimerTask() {
override fun run() { override fun run() {
try {
players = ratingsHandlers.values.filter { it.active }.flatMapTo(Json.MutableArray()) { ratings -> players = ratingsHandlers.values.filter { it.active }.flatMapTo(Json.MutableArray()) { ratings ->
ratings.fetchPlayers() ratings.fetchPlayers()
} }
val updated = ratingsHandlers.values.filter { it.active }.map { it.updated() }.reduce { u1, u2 ->
u1 or u2
}
if (updated) {
try {
updateLock.writeLock().lock()
index.build(players)
} finally {
updateLock.writeLock().unlock()
}
}
} catch (e: Exception) {
logger.error("could not build or refresh index", e)
}
} }
} }
val logger = LoggerFactory.getLogger("ratings") val logger = LoggerFactory.getLogger("ratings")
@@ -40,4 +68,20 @@ object RatingsManager: Runnable {
val file = it.toFile() val file = it.toFile()
if (!file.mkdirs() && !file.isDirectory) throw Error("Property pairgoth.ratings.path must be a directory") if (!file.mkdirs() && !file.isDirectory) throw Error("Property pairgoth.ratings.path must be a directory")
} }
fun search(needle: String, aga: Boolean, egf: Boolean, ffg: Boolean): Json.Array {
try {
updateLock.readLock().lock()
var mask = 0
if (aga && ratingsHandlers[Ratings.AGA]!!.active) mask = mask or Ratings.AGA.flag
if (egf && ratingsHandlers[Ratings.EGF]!!.active) mask = mask or Ratings.EGF.flag
if (ffg && ratingsHandlers[Ratings.FFG]!!.active) mask = mask or Ratings.FFG.flag
val matches = index.match(needle, mask)
return matches.map { it -> players[it] }.toCollection(Json.MutableArray())
} finally {
updateLock.readLock().unlock()
}
}
val index = PlayerIndex()
} }

View File

@@ -0,0 +1,36 @@
package org.jeudego.pairgoth.web
import com.republicate.kson.Json
import java.io.IOException
class ApiException : IOException {
var code: Int
private set
var details: Json.Object
private set
constructor(code: Int) : super("error") {
this.code = code
details = Json.Object("message" to message)
}
constructor(code: Int, message: String?) : super(message) {
this.code = code
details = Json.Object("message" to message)
}
constructor(code: Int, cause: Exception) : super(cause) {
this.code = code
details = Json.Object("message" to "Erreur interne du serveur : " + cause.message)
}
constructor(code: Int, message: String, cause: Exception) : super(message, cause) {
this.code = code
details = Json.Object("message" to message + " : " + cause.message)
}
constructor(code: Int, details: Json.Object) : super(details.getString("message")) {
this.code = code
this.details = details
}
}

View File

@@ -0,0 +1,158 @@
package org.jeudego.pairgoth.web
import com.republicate.kson.Json
import org.jeudego.pairgoth.ratings.RatingsManager
import org.jeudego.pairgoth.util.Colorizer
import org.jeudego.pairgoth.util.parse
import org.jeudego.pairgoth.util.toString
import org.slf4j.LoggerFactory
import java.io.IOException
import java.util.*
import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class SearchServlet: HttpServlet() {
public override fun doPost(request: HttpServletRequest, response: HttpServletResponse) {
val uri = request.requestURI
logger.logRequest(request, !uri.contains(".") && uri.length > 1)
var payload: Json? = null
var reason = "OK"
try {
validateContentType(request)
val query = request.getAttribute(PAYLOAD_KEY) as Json.Object? ?: throw ApiException(HttpServletResponse.SC_BAD_REQUEST, "no payload")
val needle = query.getString("needle") ?: throw ApiException(HttpServletResponse.SC_BAD_REQUEST, "no needle")
val aga = query.getBoolean("aga") ?: false
val egf = query.getBoolean("egf") ?: false
val ffg = query.getBoolean("ffg") ?: false
payload = RatingsManager.search(needle, aga, egf, ffg)
setContentType(response)
payload.toString(response.writer)
} catch (ioe: IOException) {
logger.error(Colorizer.red("could not process call"), ioe)
reason = ioe.message ?: "unknown i/o exception"
error(request, response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, reason, ioe)
} finally {
val builder = StringBuilder()
builder.append(response.status).append(' ')
.append(reason)
if (response.status == HttpServletResponse.SC_INTERNAL_SERVER_ERROR) {
logger.trace(Colorizer.red(">> {}"), builder.toString())
} else {
logger.trace(Colorizer.green(">> {}"), builder.toString())
}
// CB TODO - should be bufferized and asynchronously written in synchronous chunks
// so that header lines from parallel requests are not mixed up in the logs ;
// synchronizing the whole request log is not desirable
for (header in response.headerNames) {
val value = response.getHeader(header)
logger.trace(Colorizer.green(">> {}: {}"), header, value)
}
if (payload != null) {
try {
logger.logPayload(">> ", payload, false)
} catch (ioe: IOException) {
}
}
}
}
@Throws(ApiException::class)
protected fun validateContentType(request: HttpServletRequest) {
// extract content type parts
val contentType = request.contentType
if (contentType == null) {
if (request.method == "GET") return
throw ApiException(
HttpServletResponse.SC_BAD_REQUEST,
"no content type header"
)
}
val sep = contentType.indexOf(';')
val mimeType: String
var charset: String? = null
if (sep == -1) mimeType = contentType else {
mimeType = contentType.substring(0, sep).trim { it <= ' ' }
val params =
contentType.substring(sep + 1).split("=".toRegex()).dropLastWhile { it.isEmpty() }
.toTypedArray()
if (params.size == 2 && params[0].lowercase(Locale.getDefault())
.trim { it <= ' ' } == "charset"
) {
charset = params[1].lowercase(Locale.getDefault()).trim { it <= ' ' }
.replace("-".toRegex(), "")
}
}
// check charset
if (charset != null && EXPECTED_CHARSET != charset.lowercase(Locale.ROOT).replace("-", "")) throw ApiException(
HttpServletResponse.SC_BAD_REQUEST,
"UTF-8 content expected"
)
// check content type
if (isJson(mimeType)) {
// put Json body as request attribute
try {
Json.parse(request.reader)?.let { payload: Json ->
request.setAttribute(PAYLOAD_KEY, payload)
if (logger.isInfoEnabled) {
logger.logPayload("<< ", payload, true)
}
}
} catch (ioe: IOException) {
throw ApiException(HttpServletResponse.SC_BAD_REQUEST, ioe)
}
}
else throw ApiException(
HttpServletResponse.SC_BAD_REQUEST,
"JSON content expected"
)
}
protected fun error(
request: HttpServletRequest,
response: HttpServletResponse,
code: Int,
message: String?,
cause: Throwable? = null
) {
try {
if (code == 500 || response.isCommitted) {
logger.error(
"Request {} {} gave error {} {}",
request.method,
request.requestURI,
code,
message,
cause
)
}
response.status = code
if (response.isCommitted) return
val errorPayload = Json.Object(
"success" to false,
"error" to (message ?: "unknown error")
)
setContentType(response)
errorPayload.toString(response.writer)
} catch (ioe: IOException) {
logger.error("Could not send back error", ioe)
}
}
protected fun setContentType(response: HttpServletResponse) {
response.contentType = "application/json; charset=UTF-8"
}
companion object {
private var logger = LoggerFactory.getLogger("search")
private const val EXPECTED_CHARSET = "utf8"
const val PAYLOAD_KEY = "PAYLOAD"
fun isJson(mimeType: String) = "text/json" == mimeType || "application/json" == mimeType || mimeType.endsWith("+json")
}
}

View File

@@ -79,6 +79,7 @@
} }
.section { .section {
text-align: center;
padding: 0.5em; padding: 0.5em;
} }
@@ -152,20 +153,25 @@
position: relative; position: relative;
cursor: pointer; cursor: pointer;
transform: scale(1.2); transform: scale(1.2);
}
#lang-list { #lang-list {
position: absolute; position: fixed;
display: none; display: none;
top:100%; top:3em;
right: 1em; right: 1em;
flex-flow: column nowrap; flex-flow: column nowrap;
padding: 0.5em; padding: 0.5em;
gap: 0.5em; gap: 0.5em;
background-color: #dddddd; background-color: #dddddd;
align-items: flex-start; align-items: flex-start;
z-index: 50; z-index: 60;
&.shown { &.shown {
display: flex; display: flex;
} }
.lang {
cursor: pointer;
}
a { a {
display: inline-block; display: inline-block;
white-space: nowrap; white-space: nowrap;
@@ -175,7 +181,6 @@
} }
} }
} }
}
/* UI fixes */ /* UI fixes */

View File

@@ -32,9 +32,43 @@
justify-content: space-around; justify-content: space-around;
margin: 1px; margin: 1px;
background-color: #eeeeee; background-color: #eeeeee;
&.centered {
align-items: center;
}
}
.inline.fields {
background-color: #eeeeee;
margin-left: -0.5em;
margin-right: -0.5em;
padding-left: 0.5em;
padding-right: 0.5em;
.centered.field > label {
margin-right: 0;
}
} }
/* registration section */ /* registration section */
#player-form {
&:not(.add) {
#search-form, #search-result {
display: none;
}
}
}
#search-form {
position: relative;
}
#search-result {
position: absolute;
background-color: gray;
z-index: 2;
width:100%;
top: 100%;
padding: 1em;
overflow-y: auto;
&.hidden {
display: none;
}
}
} }

View File

@@ -36,13 +36,6 @@
</div> </div>
<div id="lang"> <div id="lang">
<i class="$translate.flags[$request.lang] flag"></i> <i class="$translate.flags[$request.lang] flag"></i>
<div id="lang-list">
#foreach($lang in $translate.flags.entrySet())
#if($lang != $request.lang)
<a class="lang" data-lang="$lang.key" href="#"><i class="$lang.value flag"></i>&nbsp;$lang.key</a>
#end
#end
</div>
</div> </div>
</div> </div>
<div id="center"> <div id="center">
@@ -60,8 +53,17 @@
</div> </div>
<div id="backdrop"></div> <div id="backdrop"></div>
<div id="dimmer"></div> <div id="dimmer"></div>
<div id="lang-list">
#foreach($lang in $translate.flags.entrySet())
#if($lang != $request.lang)
<a class="lang" data-lang="$lang.key" href="#"><i class="$lang.value flag"></i>&nbsp;$lang.key</a>
#end
#end
</div>
<script type="text/javascript" src="/lib/store2-2.14.2.min.js"></script> <script type="text/javascript" src="/lib/store2-2.14.2.min.js"></script>
<script type="text/javascript" src="/lib/tablesort-5.4.0.min.js"></script> <script type="text/javascript" src="/lib/tablesort-5.4.0/tablesort.min.js"></script>
<script type="text/javascript" src="/lib/tablesort-5.4.0/sorts/tablesort.number.min.js"></script>
<link rel="stylesheet" href="/lib/tablesort-5.4.0/tablesort.css"/>
<script type="text/javascript" src="/lib/imaskjs-7.1.3/imask.min.js"></script> <script type="text/javascript" src="/lib/imaskjs-7.1.3/imask.min.js"></script>
<script type="text/javascript" src="/js/api.js"></script> <script type="text/javascript" src="/js/api.js"></script>
<script type="text/javascript" src="/js/main.js"></script> <script type="text/javascript" src="/js/main.js"></script>

View File

@@ -65,6 +65,12 @@
<load-on-startup>1</load-on-startup> <load-on-startup>1</load-on-startup>
<async-supported>true</async-supported> <async-supported>true</async-supported>
</servlet> </servlet>
<servlet>
<servlet-name>search</servlet-name>
<servlet-class>org.jeudego.pairgoth.web.SearchServlet</servlet-class>
<load-on-startup>1</load-on-startup>
<async-supported>true</async-supported>
</servlet>
<!-- servlet mappings --> <!-- servlet mappings -->
<servlet-mapping> <servlet-mapping>
@@ -77,7 +83,11 @@
</servlet-mapping> </servlet-mapping>
<servlet-mapping> <servlet-mapping>
<servlet-name>api</servlet-name> <servlet-name>api</servlet-name>
<url-pattern>/api/*</url-pattern> <url-pattern>/api/tour/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>search</servlet-name>
<url-pattern>/api/search/*</url-pattern>
</servlet-mapping> </servlet-mapping>
<!-- context params --> <!-- context params -->

View File

@@ -105,7 +105,8 @@ Element.prototype.modal = function(show) {
/* DOM helpers */ /* DOM helpers */
HTMLFormElement.prototype.val = function(name) { HTMLFormElement.prototype.val = function(name, value) {
let hasValue = typeof(value) !== 'undefined';
let ctl = this.find(`[name="${name}"]`)[0]; let ctl = this.find(`[name="${name}"]`)[0];
if (!ctl) { if (!ctl) {
console.error(`unknown input name: ${name}`) console.error(`unknown input name: ${name}`)
@@ -113,15 +114,30 @@ HTMLFormElement.prototype.val = function(name) {
let tag = ctl.tagName; let tag = ctl.tagName;
let type = tag === 'INPUT' ? ctl.attr('type') : undefined; let type = tag === 'INPUT' ? ctl.attr('type') : undefined;
if ( if (
(tag === 'INPUT' && ['text', 'number'].includes(ctl.attr('type'))) || (tag === 'INPUT' && ['text', 'number', 'hidden'].includes(ctl.attr('type'))) ||
tag === 'SELECT' tag === 'SELECT'
) { ) {
return ctl.value; if (hasValue) {
ctl.value = value;
return;
}
else return ctl.value;
} else if (tag === 'INPUT' && ctl.attr('type') === 'radio') { } else if (tag === 'INPUT' && ctl.attr('type') === 'radio') {
ctl = $(`input[name="${name}"]:checked`)[0]; if (hasValue) {
if (ctl) return ctl.value; ctl = $(`input[name="${name}"][value="${value}"]`);
if (ctl) ctl.checked = true;
return;
} else {
ctl = $(`input[name="${name}"]:checked`);
if (ctl) return ctl[0].value;
else return null;
}
} else if (tag === 'INPUT' && ctl.attr('type') === 'checkbox') { } else if (tag === 'INPUT' && ctl.attr('type') === 'checkbox') {
return ctl.checked; if (hasValue) {
ctl.checked = value !== 'false' && Boolean(value);
return;
}
else return ctl.checked;
} }
console.error(`unhandled input tag or type for input ${name} (tag: ${tag}, type:${type}`); console.error(`unhandled input tag or type for input ${name} (tag: ${tag}, type:${type}`);
return null; return null;
@@ -142,6 +158,11 @@ function modal(id) {
$(`#${id}.popup`).addClass('shown'); $(`#${id}.popup`).addClass('shown');
} }
function close_modal() {
$('body').removeClass('dimmed');
$(`.popup`).removeClass('shown');
}
onLoad(() => { onLoad(() => {
$('button.close').on('click', e => { $('button.close').on('click', e => {
let modal = e.target.closest('.popup'); let modal = e.target.closest('.popup');

View File

@@ -5,8 +5,23 @@ onLoad(() => {
min: 0, min: 0,
max: 4000 max: 4000
}); });
new Tablesort($('#players')[0]);
$('#add').on('click', e => {
let form = $('#player-form')[0];
form.addClass('add');
// $('#player-form input.participation').forEach(chk => chk.checked = true);
form.reset();
modal('player');
});
$('#cancel-register').on('click', e => {
e.preventDefault();
close_modal();
return false;
});
$('#register').on('click', e => { $('#register').on('click', e => {
let form = e.target.closest('form'); let form = e.target.closest('form');
let valid = true;
let required = ['name', 'firstname', 'country', 'club', 'rank', 'rating']; let required = ['name', 'firstname', 'country', 'club', 'rank', 'rating'];
for (let name of required) { for (let name of required) {
let ctl = form.find(`[name=${name}]`)[0]; let ctl = form.find(`[name=${name}]`)[0];
@@ -29,9 +44,11 @@ onLoad(() => {
rating: form.val('rating'), rating: form.val('rating'),
rank: form.val('rank'), rank: form.val('rank'),
country: form.val('country'), country: form.val('country'),
club: form.val('club') club: form.val('club'),
skip: form.find('input.participation').map((input,i) => [i+1, input.checked]).filter(arr => !arr[1]).map(arr => arr[0])
} }
console.log(player); console.log(player);
if (form.hasClass('add')) {
api.postJson(`tour/${tour_id}/part`, player) api.postJson(`tour/${tour_id}/part`, player)
.then(player => { .then(player => {
console.log(player) console.log(player)
@@ -39,5 +56,53 @@ onLoad(() => {
window.location.reload(); window.location.reload();
} }
}); });
} else {
let id = form.val('id');
player['id'] = id;
api.putJson(`tour/${tour_id}/part/${id}`, player)
.then(player => {
console.log(player)
if (player !== 'error') {
window.location.reload();
}
});
}
});
$('#players > tbody > tr').on('click', e => {
let id = e.target.closest('tr').attr('data-id');
api.getJson(`tour/${tour_id}/part/${id}`)
.then(player => {
if (player !== 'error') {
let form = $('#player-form')[0];
form.val('id', player.id);
form.val('name', player.name);
form.val('firstname', player.firstname);
form.val('rating', player.rating);
form.val('rank', player.rank);
form.val('country', player.country);
form.val('club', player.club);
for (r = 1; r <= tour_rounds; ++r) {
form.val(`r${r}`, !(player.skip && player.skip.includes(r)));
}
form.removeClass('add');
modal('player');
}
});
});
$('#needle').on('input', e => {
let needle = $('#needle')[0].value;
if (needle && needle.length > 2) {
let form = $('#player-form')[0];
let search = {
needle: needle,
aga: form.val('aga'),
egf: form.val('egf'),
ffg: form.val('ffg')
}
api.postJson('search', search)
.then(result => {
console.log(result);
})
} else $('#search-result').addClass('hidden');
}); });
}); });

View File

@@ -0,0 +1,6 @@
/*!
* tablesort v5.4.0 (2023-05-04)
* http://tristen.ca/tablesort/demo/
* Copyright (c) 2023 ; Licensed MIT
*/
!function(){function r(e){return e=(e=e.replace(/\-/g,"/")).replace(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})/,"$3-$2-$1"),new Date(e).getTime()||-1}Tablesort.extend("date",function(e){return(-1!==e.search(/(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\.?\,?\s*/i)||-1!==e.search(/\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}/)||-1!==e.search(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)/i))&&!isNaN(r(e))},function(e,n){return e=e.toLowerCase(),n=n.toLowerCase(),r(n)-r(e)})}();

View File

@@ -0,0 +1,6 @@
/*!
* tablesort v5.4.0 (2023-05-04)
* http://tristen.ca/tablesort/demo/
* Copyright (c) 2023 ; Licensed MIT
*/
Tablesort.extend("dotsep",function(t){return/^(\d+\.)+\d+$/.test(t)},function(t,r){t=t.split("."),r=r.split(".");for(var e,n,i=0,s=t.length;i<s;i++)if((e=parseInt(t[i],10))!==(n=parseInt(r[i],10))){if(n<e)return-1;if(e<n)return 1}return 0});

View File

@@ -0,0 +1,6 @@
/*!
* tablesort v5.4.0 (2023-05-04)
* http://tristen.ca/tablesort/demo/
* Copyright (c) 2023 ; Licensed MIT
*/
!function(){function r(t){return t=t.match(/^(\d+(\.\d+)?) ?((K|M|G|T|P|E|Z|Y|B$)i?B?)$/i),parseFloat(t[1].replace(/[^\-?0-9.]/g,""))*function(t){var e="i"===(t=t.toLowerCase())[1]?1024:1e3;switch(t[0]){case"k":return Math.pow(e,2);case"m":return Math.pow(e,3);case"g":return Math.pow(e,4);case"t":return Math.pow(e,5);case"p":return Math.pow(e,6);case"e":return Math.pow(e,7);case"z":return Math.pow(e,8);case"y":return Math.pow(e,9);default:return e}}(t[3])}Tablesort.extend("filesize",function(t){return/^\d+(\.\d+)? ?(K|M|G|T|P|E|Z|Y|B$)i?B?$/i.test(t)},function(t,e){return t=r(t),e=r(e),e=e,t=t,e=parseFloat(e),t=parseFloat(t),(e=isNaN(e)?0:e)-(t=isNaN(t)?0:t)})}();

View File

@@ -0,0 +1,6 @@
/*!
* tablesort v5.4.0 (2023-05-04)
* http://tristen.ca/tablesort/demo/
* Copyright (c) 2023 ; Licensed MIT
*/
Tablesort.extend("monthname",function(e){return-1!==e.search(/(January|February|March|April|May|June|July|August|September|October|November|December)/i)},function(e,r){var u=["January","February","March","April","May","June","July","August","September","October","November","December"];return u.indexOf(r)-u.indexOf(e)});

View File

@@ -0,0 +1,6 @@
/*!
* tablesort v5.4.0 (2023-05-04)
* http://tristen.ca/tablesort/demo/
* Copyright (c) 2023 ; Licensed MIT
*/
!function(){function e(t){return t.replace(/[^\-?0-9.]/g,"")}Tablesort.extend("number",function(t){return t.match(/^[-+]?[£\x24Û¢´€]?\d+\s*([,\.]\d{0,2})/)||t.match(/^[-+]?\d+\s*([,\.]\d{0,2})?[£\x24Û¢´€]/)||t.match(/^[-+]?(\d)*-?([,\.]){0,1}-?(\d)+([E,e][\-+][\d]+)?%?$/)},function(t,n){return t=e(t),n=e(n),n=n,t=t,n=parseFloat(n),t=parseFloat(t),(n=isNaN(n)?0:n)-(t=isNaN(t)?0:t)})}();

View File

@@ -0,0 +1,33 @@
th[role=columnheader]:not(.no-sort) {
cursor: pointer;
}
th[role=columnheader]:not(.no-sort):after {
content: '';
float: right;
margin-top: 7px;
border-width: 0 4px 4px;
border-style: solid;
border-color: #404040 transparent;
visibility: hidden;
opacity: 0;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
th[aria-sort=ascending]:not(.no-sort):after {
border-bottom: none;
border-width: 4px 4px 0;
}
th[aria-sort]:not(.no-sort):after {
visibility: visible;
opacity: 0.4;
}
th[role=columnheader]:not(.no-sort):hover:after {
visibility: visible;
opacity: 1;
}

View File

@@ -1,6 +1,6 @@
/*! /*!
* tablesort v5.4.0 (2022-04-27) * tablesort v5.4.0 (2023-05-04)
* http://tristen.ca/tablesort/demo/ * http://tristen.ca/tablesort/demo/
* Copyright (c) 2022 ; Licensed MIT * Copyright (c) 2023 ; Licensed MIT
*/ */
!function(){function r(t,e){if(!(this instanceof r))return new r(t,e);if(!t||"TABLE"!==t.tagName)throw new Error("Element must be a table");this.init(t,e||{})}function m(t){var e;return window.CustomEvent&&"function"==typeof window.CustomEvent?e=new CustomEvent(t):(e=document.createEvent("CustomEvent")).initCustomEvent(t,!1,!1,void 0),e}function p(t,e){return t.getAttribute(e.sortAttribute||"data-sort")||t.textContent||t.innerText||""}function v(t,e){return(t=t.trim().toLowerCase())===(e=e.trim().toLowerCase())?0:t<e?1:-1}function A(t,e){return[].slice.call(t).find(function(t){return t.getAttribute("data-sort-column-key")===e})}function E(n,o){return function(t,e){var r=n(t.td,e.td);return 0===r?o?e.index-t.index:t.index-e.index:r}}var x=[];r.extend=function(t,e,r){if("function"!=typeof e||"function"!=typeof r)throw new Error("Pattern and sort must be a function");x.push({name:t,pattern:e,sort:r})},r.prototype={init:function(t,e){var r,n,o,i=this;if(i.table=t,i.thead=!1,i.options=e,t.rows&&0<t.rows.length)if(t.tHead&&0<t.tHead.rows.length){for(a=0;a<t.tHead.rows.length;a++)if("thead"===t.tHead.rows[a].getAttribute("data-sort-method")){r=t.tHead.rows[a];break}r=r||t.tHead.rows[t.tHead.rows.length-1],i.thead=!0}else r=t.rows[0];if(r){function s(){i.current&&i.current!==this&&i.current.removeAttribute("aria-sort"),i.current=this,i.sortTable(this)}for(var a=0;a<r.cells.length;a++)(o=r.cells[a]).setAttribute("role","columnheader"),"none"!==o.getAttribute("data-sort-method")&&(o.tabindex=0,o.addEventListener("click",s,!1),null!==o.getAttribute("data-sort-default")&&(n=o));n&&(i.current=n,i.sortTable(n))}},sortTable:function(t,e){var r=this,n=t.getAttribute("data-sort-column-key"),o=t.cellIndex,i=v,s="",a=[],d=r.thead?0:1,u=t.getAttribute("data-sort-method"),l=t.getAttribute("aria-sort");if(r.table.dispatchEvent(m("beforeSort")),e||(l="ascending"===l||"descending"!==l&&r.options.descending?"descending":"ascending",t.setAttribute("aria-sort",l)),!(r.table.rows.length<2)){if(!u){for(;a.length<3&&d<r.table.tBodies[0].rows.length;)0<(s=(s=(f=n?A(r.table.tBodies[0].rows[d].cells,n):r.table.tBodies[0].rows[d].cells[o])?p(f,r.options):"").trim()).length&&a.push(s),d++;if(!a)return}for(d=0;d<x.length;d++)if(s=x[d],u){if(s.name===u){i=s.sort;break}}else if(a.every(s.pattern)){i=s.sort;break}for(r.col=o,d=0;d<r.table.tBodies.length;d++){var c,f,h=[],b={},w=0,g=0;if(!(r.table.tBodies[d].rows.length<2)){for(c=0;c<r.table.tBodies[d].rows.length;c++)"none"===(s=r.table.tBodies[d].rows[c]).getAttribute("data-sort-method")?b[w]=s:(f=n?A(s.cells,n):s.cells[r.col],h.push({tr:s,td:f?p(f,r.options):"",index:w})),w++;for("descending"===l?h.sort(E(i,!0)):(h.sort(E(i,!1)),h.reverse()),c=0;c<w;c++)b[c]?(s=b[c],g++):s=h[c-g].tr,r.table.tBodies[d].appendChild(s)}}r.table.dispatchEvent(m("afterSort"))}},refresh:function(){void 0!==this.current&&this.sortTable(this.current,!0)}},"undefined"!=typeof module&&module.exports?module.exports=r:window.Tablesort=r}(); !function(){function r(t,e){if(!(this instanceof r))return new r(t,e);if(!t||"TABLE"!==t.tagName)throw new Error("Element must be a table");this.init(t,e||{})}function m(t){var e;return window.CustomEvent&&"function"==typeof window.CustomEvent?e=new CustomEvent(t):(e=document.createEvent("CustomEvent")).initCustomEvent(t,!1,!1,void 0),e}function p(t,e){return t.getAttribute(e.sortAttribute||"data-sort")||t.textContent||t.innerText||""}function v(t,e){return(t=t.trim().toLowerCase())===(e=e.trim().toLowerCase())?0:t<e?1:-1}function A(t,e){return[].slice.call(t).find(function(t){return t.getAttribute("data-sort-column-key")===e})}function E(n,o){return function(t,e){var r=n(t.td,e.td);return 0===r?o?e.index-t.index:t.index-e.index:r}}var x=[];r.extend=function(t,e,r){if("function"!=typeof e||"function"!=typeof r)throw new Error("Pattern and sort must be a function");x.push({name:t,pattern:e,sort:r})},r.prototype={init:function(t,e){var r,n,o,i=this;if(i.table=t,i.thead=!1,i.options=e,t.rows&&0<t.rows.length)if(t.tHead&&0<t.tHead.rows.length){for(a=0;a<t.tHead.rows.length;a++)if("thead"===t.tHead.rows[a].getAttribute("data-sort-method")){r=t.tHead.rows[a];break}r=r||t.tHead.rows[t.tHead.rows.length-1],i.thead=!0}else r=t.rows[0];if(r){function s(){i.current&&i.current!==this&&i.current.removeAttribute("aria-sort"),i.current=this,i.sortTable(this)}for(var a=0;a<r.cells.length;a++)(o=r.cells[a]).setAttribute("role","columnheader"),"none"!==o.getAttribute("data-sort-method")&&(o.tabindex=0,o.addEventListener("click",s,!1),null!==o.getAttribute("data-sort-default")&&(n=o));n&&(i.current=n,i.sortTable(n))}},sortTable:function(t,e){var r=this,n=t.getAttribute("data-sort-column-key"),o=t.cellIndex,i=v,s="",a=[],d=r.thead?0:1,u=t.getAttribute("data-sort-method"),l=t.getAttribute("aria-sort");if(r.table.dispatchEvent(m("beforeSort")),e||(l="ascending"===l||"descending"!==l&&r.options.descending?"descending":"ascending",t.setAttribute("aria-sort",l)),!(r.table.rows.length<2)){if(!u){for(;a.length<3&&d<r.table.tBodies[0].rows.length;)0<(s=(s=(f=n?A(r.table.tBodies[0].rows[d].cells,n):r.table.tBodies[0].rows[d].cells[o])?p(f,r.options):"").trim()).length&&a.push(s),d++;if(!a)return}for(d=0;d<x.length;d++)if(s=x[d],u){if(s.name===u){i=s.sort;break}}else if(a.every(s.pattern)){i=s.sort;break}for(r.col=o,d=0;d<r.table.tBodies.length;d++){var c,f,h=[],b={},w=0,g=0;if(!(r.table.tBodies[d].rows.length<2)){for(c=0;c<r.table.tBodies[d].rows.length;c++)"none"===(s=r.table.tBodies[d].rows[c]).getAttribute("data-sort-method")?b[w]=s:(f=n?A(s.cells,n):s.cells[r.col],h.push({tr:s,td:f?p(f,r.options):"",index:w})),w++;for("descending"===l?h.sort(E(i,!0)):(h.sort(E(i,!1)),h.reverse()),c=0;c<w;c++)b[c]?(s=b[c],g++):s=h[c-g].tr,r.table.tBodies[d].appendChild(s)}}r.table.dispatchEvent(m("afterSort"))}},refresh:function(){void 0!==this.current&&this.sortTable(this.current,!0)}},"undefined"!=typeof module&&module.exports?module.exports=r:window.Tablesort=r}();

View File

@@ -57,14 +57,14 @@
<label>Tournament type</label> <label>Tournament type</label>
<span class="info"></span> <span class="info"></span>
<select name="type"> <select name="type">
<option value="INDIVIDUAL" #if(!$tour || $tour.type == 'INDIVIDUAL') checked #end>Individual players</option> <option value="INDIVIDUAL" #if(!$tour || $tour.type == 'INDIVIDUAL') selected #end>Individual players</option>
<option value="PAIRGO" #if($tour && $tour.type == 'PAIRGO') checked #end>Pair-go tournament</option> <option value="PAIRGO" #if($tour && $tour.type == 'PAIRGO') selected #end>Pair-go tournament</option>
<option value="RENGO2" #if($tour && $tour.type == 'RENGO2') checked #end>Rengo with 2 players teams</option> <option value="RENGO2" #if($tour && $tour.type == 'RENGO2') selected #end>Rengo with 2 players teams</option>
<option value="RENGO3" #if($tour && $tour.type == 'RENGO3') checked #end>Rengo with 3 players team</option> <option value="RENGO3" #if($tour && $tour.type == 'RENGO3') selected #end>Rengo with 3 players team</option>
<option value="TEAM2" #if($tour && $tour.type == 'TEAM2') checked #end>Team of 2 individual players</option> <option value="TEAM2" #if($tour && $tour.type == 'TEAM2') selected #end>Team of 2 individual players</option>
<option value="TEAM3" #if($tour && $tour.type == 'TEAM3') checked #end>Team of 3 individual players</option> <option value="TEAM3" #if($tour && $tour.type == 'TEAM3') selected #end>Team of 3 individual players</option>
<option value="TEAM4" #if($tour && $tour.type == 'TEAM4') checked #end>Team of 4 individual players</option> <option value="TEAM4" #if($tour && $tour.type == 'TEAM4') selected #end>Team of 4 individual players</option>
<option value="TEAM5" #if($tour && $tour.type == 'TEAM5') checked #end>Team of 5 individual players</option> <option value="TEAM5" #if($tour && $tour.type == 'TEAM5') selected #end>Team of 5 individual players</option>
</select> </select>
</div> </div>
<div class="four wide field"> <div class="four wide field">
@@ -78,9 +78,9 @@
<label>Pairing</label> <label>Pairing</label>
<span class="info"></span> <span class="info"></span>
<select name="pairing"> <select name="pairing">
<option value="MAC_MAHON" #if(!$tour || $tour.pairing.type == 'MAC_MAHON') checked #end>Mac Mahon</option> <option value="MAC_MAHON" #if(!$tour || $tour.pairing.type == 'MAC_MAHON') selected #end>Mac Mahon</option>
<option value="SWISS" #if($tour && $tour.pairing.type == 'SWISS') checked #end>Swiss</option> <option value="SWISS" #if($tour && $tour.pairing.type == 'SWISS') selected #end>Swiss</option>
<option value="ROUND_ROBIN" #if($tour && $tour.pairing.type == 'ROUND_ROBIN') checked #end>Round-robin</option> <option value="ROUND_ROBIN" #if($tour && $tour.pairing.type == 'ROUND_ROBIN') selected #end>Round-robin</option>
</select> </select>
</div> </div>
#* MM floor parameter not shown on creation page #* MM floor parameter not shown on creation page
@@ -117,8 +117,7 @@
#levels($limit) #levels($limit)
</select> </select>
</div> </div>
</div> <div class="swiss pairing six wide field #if(!$tour || $tour && $tour.pairing.type != 'SWISS') hidden #end">
<div class="swiss pairing four wide field #if(!$tour || $tour && $tour.pairing.type != 'SWISS') hidden #end">
<label>1st round seeding</label> <label>1st round seeding</label>
<span class="info"></span> <span class="info"></span>
<select name="firstSeed"> <select name="firstSeed">
@@ -127,7 +126,7 @@
<option value="SPLIT_AND_SLIP" #if($tour && "$!tour.pairing.main.firstSeed" == "SPLIT_AND_SLIP") selected #end>Split and slip</option> <option value="SPLIT_AND_SLIP" #if($tour && "$!tour.pairing.main.firstSeed" == "SPLIT_AND_SLIP") selected #end>Split and slip</option>
</select> </select>
</div> </div>
<div class="swiss pairing four wide field #if(!$tour || $tour && $tour.pairing.type != 'SWISS')hidden#end"> <div class="swiss pairing six wide field #if(!$tour || $tour && $tour.pairing.type != 'SWISS')hidden#end">
<label>Next rounds seeding</label> <label>Next rounds seeding</label>
<span class="info"></span> <span class="info"></span>
<select name="secondSeed"> <select name="secondSeed">
@@ -137,6 +136,7 @@
</select> </select>
</div> </div>
</div> </div>
</div>
<div class="roundbox"> <div class="roundbox">
<div class="three stackable fields"> <div class="three stackable fields">
<div class="seven wide field"> <div class="seven wide field">

View File

@@ -2,7 +2,39 @@
<div id="reg-view"> <div id="reg-view">
<div id="players-list" class="roundbox"> <div id="players-list" class="roundbox">
#set($parts = $api.get("tour/${params.id}/part")) #set($parts = $api.get("tour/${params.id}/part"))
$parts <table id="players" class="ui celled selectable striped table">
<thead>
<th>name</th>
<th>first name</th>
<th>country</th>
<th>club</th>
<th>rank</th>
<th>rating</th>
<th>participation</th>
</thead>
<tbody>
#foreach($part in $parts)
<tr data-id="$part.id">
<td>$part.name</td>
<td>$part.firstname</td>
<td>$part.country</td>
<td>$part.club</td>
<td data-sort="$part.rank">#rank($part.rank)</td>
<td>$part.rating</td>
<td>
#foreach($round in [1..$tour.rounds])
## CB TODO - upstream json parsing should not give longs here, should it?
#if($part.skip && $part.skip.contains($round.longValue()))
<label class="ui red circular label">$round</label>
#else
<label class="ui green circular label">$round</label>
#end
#end
</td>
</tr>
#end
</tbody>
</table>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button id="add" class="ui blue right labeled icon floating info button"> <button id="add" class="ui blue right labeled icon floating info button">
@@ -15,35 +47,37 @@ $parts
<div id="player" class="popup"> <div id="player" class="popup">
<div class="popup-body"> <div class="popup-body">
<form id="player-form" class="ui form edit"> <form id="player-form" class="ui form edit">
<input type="hidden" name="id"/>
<div class="popup-content"> <div class="popup-content">
<div class="four stackable fields"> <div id="search-form" class="four stackable fields">
<div class="twelve wide field"> <div class="twelve wide field">
<div class="ui icon input"> <div class="ui icon input">
<input type="text" placeholder="Search..."> <input id="needle" name="needle" type="text" placeholder="Search...">
<i class="search icon"></i> <i class="search icon"></i>
</div> </div>
</div> </div>
<div class="two wide field"> <div class="two wide field">
<div class="active checkbox"> <div class="active checkbox">
<div class="circle"></div> <div class="circle"></div>
<input type="checkbox" class="hidden" checked/> <input name="aga" type="checkbox" class="hidden" checked/>
</div> </div>
AGA AGA
</div> </div>
<div class="two wide field"> <div class="two wide field">
<div class="active checkbox"> <div class="active checkbox">
<div class="circle"></div> <div class="circle"></div>
<input type="checkbox" class="hidden" checked/> <input name="egf" type="checkbox" class="hidden" checked/>
</div> </div>
EGF EGF
</div> </div>
<div class="two wide field"> <div class="two wide field">
<div class="active checkbox"> <div class="active checkbox">
<div class="circle"></div> <div class="circle"></div>
<input type="checkbox" class="hidden" checked/> <input name="ffg" type="checkbox" class="hidden" checked/>
</div> </div>
FFG FFG
</div> </div>
<div id="search-result" class="hidden">hophop</div>
</div> </div>
<div class="two stackable fields"> <div class="two stackable fields">
<div class="eight wide field"> <div class="eight wide field">
@@ -90,9 +124,18 @@ $parts
<input name="rating" type="text" class="numeric"/> <input name="rating" type="text" class="numeric"/>
</div> </div>
</div> </div>
<div class="inline fields">
<label>Participation</label>
#foreach($r in [1..$tour.rounds])
<div class="centered field">
<label>R${r}</label>
<input name="r${r}" type="checkbox" checked="checked" class="participation"/>
</div>
#end
</div>
</div> </div>
<div class="popup-footer"> <div class="popup-footer">
<button class="ui gray right labeled icon floating close button"> <button id="cancel-register" class="ui gray right labeled icon floating close button">
<i class="times icon"></i> <i class="times icon"></i>
Cancel Cancel
</button> </button>
@@ -104,10 +147,3 @@ $parts
</form> </form>
</div> </div>
</div> </div>
<script type="text/javascript">
onLoad(() => {
$('#add').on('click', e => {
modal('player');
});
});
</script>

View File

@@ -11,6 +11,7 @@
<option value="$k" #if($sel && $sel == $k)selected#end>$disp</option> <option value="$k" #if($sel && $sel == $k)selected#end>$disp</option>
#end #end
#end #end
#macro(rank $rank)#if( $rank<0 )#set( $k = -$rank )${k}k#else#set( $d=$rank+1 )${d}d#end#end
#if($params.id) #if($params.id)
#set($tour = $api.get("tour/${params.id}")) #set($tour = $api.get("tour/${params.id}"))
#if (!$tour) #if (!$tour)
@@ -63,6 +64,7 @@
<script type="text/javascript"> <script type="text/javascript">
#if($tour) #if($tour)
const tour_id = ${tour.id}; const tour_id = ${tour.id};
const tour_rounds = ${tour.rounds};
#end #end
#set($datepickerLocale = $translate.datepickerLocale($request.lang, $request.loc)) #set($datepickerLocale = $translate.datepickerLocale($request.lang, $request.loc))
const datepickerLocale = '$datepickerLocale'; const datepickerLocale = '$datepickerLocale';