diff --git a/app/controllers/User.scala b/app/controllers/User.scala
index 46ecfa98547d..aba1df138d2a 100644
--- a/app/controllers/User.scala
+++ b/app/controllers/User.scala
@@ -346,8 +346,10 @@ final class User(
ctx: Context,
me: Me
): Fu[Result] =
- env.user.api.withPerfsAndEmails(username).orFail(s"No such user $username").flatMap {
- case WithPerfsAndEmails(user, emails) =>
+ env.report.api.inquiries
+ .ofModId(me.id)
+ .zip(env.user.api.withPerfsAndEmails(username).orFail(s"No such user $username"))
+ .flatMap { case (inquiry, WithPerfsAndEmails(user, emails)) =>
import views.mod.{ user as ui }
import lila.ui.ScalatagsExtensions.{ emptyFrag, given }
given lila.mod.IpRender.RenderIp = env.mod.ipRender.apply
@@ -356,7 +358,10 @@ final class User(
val timeline = env.api.modTimeline
.load(user, withPlayBans = true)
- .map(views.mod.timeline.renderGeneral)
+ .map: tl =>
+ if inquiry.exists(_.isPlay)
+ then views.mod.timeline.renderPlay(tl)
+ else views.mod.timeline.renderGeneral(tl)
.map(lila.mod.ui.mzSection("timeline")(_))
val plan =
@@ -444,7 +449,7 @@ final class User(
.log("User.renderModZone")
.as(ContentTypes.EVENT_STREAM)
.pipe(noProxyBuffer)
- }
+ }
protected[controllers] def renderModZoneActions(username: UserStr)(using ctx: Context) =
env.user.api.withPerfsAndEmails(username).orFail(s"No such user $username").flatMap {
diff --git a/bin/git-hooks/pre-commit b/bin/git-hooks/pre-commit
index 1fba7d221f86..25b67606983a 100755
--- a/bin/git-hooks/pre-commit
+++ b/bin/git-hooks/pre-commit
@@ -7,11 +7,12 @@ if \git --no-pager diff-index -z --name-only --no-renames --cached HEAD | \
\grep -qzE '\.(json|scss|ts)$'; then
# NOTE! grep must be kept in sync with lint-staged in package.json!
- LINT_STAGED="$(dirname -- "$0")/../../node_modules/.bin/lint-staged"
+ BIN_DIR="$(dirname -- $0)/.."
+ LINT_STAGED="$BIN_DIR/../node_modules/.bin/lint-staged"
# pnpm or npx adds .25s overhead. exec further reduces overhead.
if [ -x "$LINT_STAGED" ]; then
- exec "$LINT_STAGED"
+ exec "$LINT_STAGED" --config "$BIN_DIR/lint-staged.config.mjs"
else
exec pnpm lint-staged
fi
diff --git a/bin/lint-staged.config.mjs b/bin/lint-staged.config.mjs
new file mode 100644
index 000000000000..108540013370
--- /dev/null
+++ b/bin/lint-staged.config.mjs
@@ -0,0 +1,8 @@
+import { lstatSync } from 'fs';
+
+export default {
+ '*.{json,scss,ts}': files => {
+ const regularFiles = files.filter(f => !lstatSync(f).isSymbolicLink());
+ return regularFiles.length ? `prettier --write ${regularFiles.join(' ')}` : 'true';
+ },
+};
diff --git a/build.sbt b/build.sbt
index 5bb9926e8079..966b882329d9 100644
--- a/build.sbt
+++ b/build.sbt
@@ -491,7 +491,6 @@ lazy val tree = module("tree",
Seq(chess.playJson)
)
-// todo remove common, move common.Icon to ui.Icon
lazy val ui = module("ui",
Seq(core, coreI18n),
Seq()
diff --git a/modules/api/src/main/ModTimeline.scala b/modules/api/src/main/ModTimeline.scala
index 0607c57cca6f..51a7f3ad3fe0 100644
--- a/modules/api/src/main/ModTimeline.scala
+++ b/modules/api/src/main/ModTimeline.scala
@@ -53,9 +53,13 @@ object ModTimeline:
case (p: PublicLine, n: PublicLine) => PublicLine.merge(p, n)
case (p: PlayBans, n: PlayBans) => PlayBans(p.list ::: n.list).some
case (p: AppealMsg, n: AppealMsg) if p.by.is(n.by) => p.copy(text = s"${n.text}\n\n${p.text}").some
- case (p: ReportNewAtom, n: ReportNewAtom) if n.like(p.report) =>
- p.copy(atoms = n.atoms ::: p.atoms).some
- case _ => none
+ case (p: ReportNewAtom, n: ReportNewAtom) if n.like(p.report) => p.copy(atoms = n.atoms ::: p.atoms).some
+ case (p: Modlog, n: Modlog) => mergeModlog(p, n)
+ case _ => none
+
+ private def mergeModlog(p: Modlog, n: Modlog): Option[Modlog] =
+ (p.action == n.action && p.mod.is(n.mod)).option:
+ p.copy(details = some(List(p.details, n.details).flatten.distinct.mkString(" / ")))
private def reportAtoms(report: Report): List[ReportNewAtom | PublicLine] =
report.atoms
@@ -109,13 +113,21 @@ object ModTimeline:
case Play
object Angle:
def filter(e: Event)(using angle: Angle): Boolean = e match
- case _: PlayBans => angle != Angle.Comm
- case l: Modlog if l.action == Modlog.chatTimeout && angle != Angle.Comm => false
+ case _: PlayBans => angle != Angle.Comm
+ case l: Modlog if l.action == Modlog.chatTimeout => angle == Angle.Comm
+ case l: Modlog if l.action == Modlog.deletePost => angle != Angle.Play
+ case l: Modlog if l.action == Modlog.disableTeam => angle != Angle.Play
+ case l: Modlog if l.action == Modlog.teamKick => angle != Angle.Play
+ case l: Modlog if l.action == Modlog.blankedPassword => angle == Angle.None
+ case l: Modlog if l.action == Modlog.weakPassword => angle == Angle.None
+ case l: Modlog if l.action == Modlog.troll => angle != Angle.Play
case l: Modlog if l.action == Modlog.modMessage =>
angle match
case Comm => !l.details.has(lila.playban.PlaybanFeedback.sittingAutoPreset.name)
case _ => true
- case _ => true
+ case r: ReportNewAtom if r.report.is(_.Comm) => angle != Angle.Play
+ case _: PublicLine => angle != Angle.Play
+ case _ => true
final class ModTimelineApi(
modLogApi: ModlogApi,
@@ -150,7 +162,8 @@ final class ModTimelineApi(
else if note.text.startsWith("Appeal reply:") then false
else true
- private def filterReport(r: Report): Boolean = !r.isSpontaneous
+ private def filterReport(r: Report): Boolean =
+ !r.isSpontaneous && !r.isAppeal
private object modsList:
var all: Set[ModId] = Set(UserId.lichess.into(ModId))
diff --git a/modules/common/src/main/mon.scala b/modules/common/src/main/mon.scala
index 432222e5dd11..6ebd4ca03991 100644
--- a/modules/common/src/main/mon.scala
+++ b/modules/common/src/main/mon.scala
@@ -726,4 +726,4 @@ object mon:
private def apiTag(api: Option[ApiVersion]) = api.fold("-")(_.toString)
import scala.language.implicitConversions
- given Conversion[Map[String, Any], TagSet] = TagSet.from
+ private given Conversion[Map[String, Any], TagSet] = TagSet.from
diff --git a/modules/core/src/main/data.scala b/modules/core/src/main/data.scala
index eb997fb94e10..a4b9dcc1f41e 100644
--- a/modules/core/src/main/data.scala
+++ b/modules/core/src/main/data.scala
@@ -57,3 +57,12 @@ object data:
def sync[A](v: => A): LazyFu[A] = LazyFu(() => Future.successful(v))
case class CircularDep[A](resolve: () => A)
+
+ final class SimpleMemo[A](ttl: Option[FiniteDuration])(compute: () => A):
+ private var value: A = compute()
+ private var recomputeAt: Option[Instant] = ttl.map(nowInstant.plus(_))
+ def get(): A =
+ if recomputeAt.exists(_.isBeforeNow) then
+ recomputeAt = ttl.map(nowInstant.plus(_))
+ value = compute()
+ value
diff --git a/modules/feed/src/main/FeedUi.scala b/modules/feed/src/main/FeedUi.scala
index 7c5624246c95..a4c4e27a2403 100644
--- a/modules/feed/src/main/FeedUi.scala
+++ b/modules/feed/src/main/FeedUi.scala
@@ -171,10 +171,7 @@ final class FeedUi(helpers: Helpers, atomUi: AtomUi)(
)(form3.textarea(_)(rows := 10)),
form3.group(form("flair"), "Icon", half = false): field =>
form3
- .flairPicker(field, Flair.from(form("flair").value), label = frag("Update icon"), anyFlair = true):
- span(cls := "flair-container"):
- Flair.from(form("flair").value).map(f => marker(f.some, "uflair".some))
- ,
+ .flairPicker(field, Flair.from(form("flair").value), anyFlair = true),
form3.action(form3.submit("Save"))
)
diff --git a/modules/mod/src/main/ui/ModUserUi.scala b/modules/mod/src/main/ui/ModUserUi.scala
index f43ccbe4f33c..876bed2c0724 100644
--- a/modules/mod/src/main/ui/ModUserUi.scala
+++ b/modules/mod/src/main/ui/ModUserUi.scala
@@ -359,7 +359,7 @@ final class ModUserUi(helpers: Helpers, modUi: ModUi):
postForm(action := routes.Report.inquiry(r.id.value))(
reportSubmitButton(r),
" ",
- userIdLink(r.user.some),
+ userIdLink(r.user.some, withOnline = false),
" ",
momentFromNowServer(atom.at),
": ",
diff --git a/modules/pref/src/main/ui/AccountPages.scala b/modules/pref/src/main/ui/AccountPages.scala
index 8a609832f57e..fc049f12eb09 100644
--- a/modules/pref/src/main/ui/AccountPages.scala
+++ b/modules/pref/src/main/ui/AccountPages.scala
@@ -62,9 +62,7 @@ final class AccountPages(helpers: Helpers, ui: AccountUi, flagApi: lila.core.use
): f =>
form3.textarea(f)(rows := 5)
),
- form3.flairPickerGroup(form("flair"), u.flair, label = trans.site.setFlair())(
- userSpan(u, withPowerTip = false, cssClass = "flair-container".some)
- ):
+ form3.flairPickerGroup(form("flair"), u.flair):
p(cls := "form-help"):
a(
href := s"${routes.Pref.form("display")}#showFlairs",
diff --git a/modules/puzzle/src/main/PuzzleBatch.scala b/modules/puzzle/src/main/PuzzleBatch.scala
index d9f62b769a97..4895d60b5333 100644
--- a/modules/puzzle/src/main/PuzzleBatch.scala
+++ b/modules/puzzle/src/main/PuzzleBatch.scala
@@ -20,6 +20,7 @@ final class PuzzleBatch(colls: PuzzleColls, anonApi: PuzzleAnon, pathApi: Puzzle
me.foldUse(anonApi.getBatchFor(angle, difficulty, nb)): me ?=>
val tier =
if perf.nb > 5000 then PuzzleTier.good
+ else if angle.opening.isDefined then PuzzleTier.good
else if PuzzleDifficulty.isExtreme(difficulty) then PuzzleTier.good
else PuzzleTier.top
pathApi
diff --git a/modules/relay/src/main/RelayListing.scala b/modules/relay/src/main/RelayListing.scala
index 08b867cc64d8..cb3b1a34dd1c 100644
--- a/modules/relay/src/main/RelayListing.scala
+++ b/modules/relay/src/main/RelayListing.scala
@@ -1,6 +1,7 @@
package lila.relay
import reactivemongo.api.bson.*
+import monocle.syntax.all.*
import lila.db.dsl.{ *, given }
import lila.relay.RelayTour.ActiveWithSomeRounds
@@ -128,8 +129,9 @@ final class RelayListing(
doc <- docs
tour <- doc.asOpt[RelayTour]
round <- doc.getAsOpt[RelayRound]("round")
- group = RelayListing.group.readFromOne(doc)
- yield (tour, round, group)
+ group = RelayListing.group.readFromOne(doc)
+ reTiered = decreaseTierOfDistantNextRound(tour, round)
+ yield (reTiered, round, group)
sorted = tours.sortBy: (tour, round, _) =>
(
!round.hasStarted, // ongoing tournaments first
@@ -150,6 +152,19 @@ final class RelayListing(
tr.display.hasStarted || tr.display.startsAtTime.exists(_.isBefore(nowInstant.plusMinutes(30)))
active
+ private def decreaseTierOfDistantNextRound(tour: RelayTour, round: RelayRound): RelayTour =
+ import RelayTour.Tier.*
+ val visualTier = for
+ tier <- tour.tier
+ nextAt <- round.startsAtTime
+ days = scalalib.time.daysBetween(nowInstant.withTimeAtStartOfDay, nextAt)
+ yield
+ if tier == BEST && days > 10 then NORMAL
+ else if tier == BEST && days > 5 then HIGH
+ else if tier == HIGH && days > 5 then NORMAL
+ else tier
+ tour.copy(tier = visualTier.orElse(tour.tier))
+
val upcoming = cacheApi.unit[List[RelayTour.WithLastRound]]:
_.refreshAfterWrite(14 seconds).buildAsyncFuture: _ =>
val max = 64
diff --git a/modules/relay/src/main/ui/RelayTourUi.scala b/modules/relay/src/main/ui/RelayTourUi.scala
index b195f321deba..b4f0f6469750 100644
--- a/modules/relay/src/main/ui/RelayTourUi.scala
+++ b/modules/relay/src/main/ui/RelayTourUi.scala
@@ -3,7 +3,6 @@ package ui
import java.time.{ Month, YearMonth }
import scalalib.paginator.Paginator
-import monocle.syntax.all.*
import lila.core.LightUser
import lila.relay.RelayTour.{ WithLastRound, WithFirstRound }
@@ -21,9 +20,8 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi):
upcoming: List[WithLastRound],
past: Seq[WithLastRound]
)(using Context) =
- val reTiered = decreaseTierOfDistantNextRound(active)
def nonEmptyTier(selector: RelayTour.Tier.Selector, tier: String) =
- val selected = reTiered.filter(_.tour.tierIs(selector))
+ val selected = active.filter(_.tour.tierIs(selector))
selected.nonEmpty.option(st.section(cls := s"relay-cards relay-cards--tier-$tier"):
selected.map:
card.render(_, live = _.display.hasStarted)
@@ -35,7 +33,7 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi):
pageMenu("index"),
div(cls := "page-menu__content box box-pad")(
boxTop(h1(trc.liveBroadcasts()), searchForm("")),
- Granter.opt(_.StudyAdmin).option(adminIndex(reTiered)),
+ Granter.opt(_.StudyAdmin).option(adminIndex(active)),
nonEmptyTier(_.BEST, "best"),
nonEmptyTier(_.HIGH, "high"),
nonEmptyTier(_.NORMAL, "normal"),
@@ -70,21 +68,6 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi):
card.render(tr.copy(link = tr.display), live = _.display.hasStarted, errors = errors.take(5))
)
- private def decreaseTierOfDistantNextRound(active: List[RelayTour.ActiveWithSomeRounds]) =
- val now = nowInstant.withTimeAtStartOfDay
- active.map: a =>
- import RelayTour.Tier.*
- val visualTier = for
- tier <- a.tour.tier
- nextAt <- a.display.startsAtTime
- days = scalalib.time.daysBetween(now, nextAt)
- yield
- if tier == BEST && days > 10 then NORMAL
- else if tier == BEST && days > 5 then HIGH
- else if tier == HIGH && days > 5 then NORMAL
- else tier
- a.focus(_.tour.tier).replace(visualTier.orElse(a.tour.tier))
-
private def listLayout(title: String, menu: Tag)(body: Modifier*)(using Context) =
Page(title)
.css("bits.relay.index")
diff --git a/modules/report/src/main/Report.scala b/modules/report/src/main/Report.scala
index dd7207647c0c..8dc42e315002 100644
--- a/modules/report/src/main/Report.scala
+++ b/modules/report/src/main/Report.scala
@@ -72,6 +72,7 @@ case class Report(
def isRecentComm = open && room == Room.Comm
def isRecentCommOf(sus: Suspect) = isRecentComm && user == sus.user.id
+ def isPlay = room == Room.Boost || room == Room.Cheat
def isAppeal = room == Room.Other && atoms.head.text == Report.appealText
def isAppealInquiryByMe(using me: MyId) = isAppeal && atoms.head.by.is(me)
diff --git a/modules/team/src/main/ui/FormUi.scala b/modules/team/src/main/ui/FormUi.scala
index 7495da33dbeb..e6e6838782b8 100644
--- a/modules/team/src/main/ui/FormUi.scala
+++ b/modules/team/src/main/ui/FormUi.scala
@@ -93,8 +93,7 @@ final class FormUi(helpers: Helpers, bits: TeamUi)(
private val explainInput = input(st.name := "explain", tpe := "hidden")
private def flairField(form: Form[?], team: Team)(using Context) =
- form3.flairPickerGroup(form("flair"), Flair.from(form("flair").value), label = trans.site.setFlair()):
- span(cls := "flair-container".some)(team.name, teamFlair(team.light))
+ form3.flairPickerGroup(form("flair"), Flair.from(form("flair").value))
private def textFields(form: Form[?])(using Context) = frag(
form3.group(
diff --git a/modules/ui/src/main/helper/Form3.scala b/modules/ui/src/main/helper/Form3.scala
index 6f6d56554937..d7cfed2820c6 100644
--- a/modules/ui/src/main/helper/Form3.scala
+++ b/modules/ui/src/main/helper/Form3.scala
@@ -7,8 +7,7 @@ import lila.core.i18n.{ I18nKey as trans, Translate }
import lila.core.user.FlairApi
import lila.ui.ScalatagsTemplate.{ *, given }
-final class Form3(formHelper: FormHelper & I18nHelper, flairApi: FlairApi):
-
+final class Form3(formHelper: FormHelper & I18nHelper & AssetHelper, flairApi: FlairApi):
import formHelper.{ transKey, given }
private val idPrefix = "form3"
@@ -246,29 +245,27 @@ final class Form3(formHelper: FormHelper & I18nHelper, flairApi: FlairApi):
)
private lazy val exceptEmojis = data("except-emojis") := flairApi.adminFlairs.mkString(" ")
- def flairPickerGroup(field: Field, current: Option[Flair], label: Frag)(view: Frag)(using Context): Tag =
+ def flairPickerGroup(field: Field, current: Option[Flair])(using Context): Tag =
group(field, trans.site.flair(), half = true): f =>
- flairPicker(f, current, label)(view)
+ flairPicker(f, current)
- def flairPicker(field: Field, current: Option[Flair], label: Frag, anyFlair: Boolean = false)(view: Frag)(
- using ctx: Context
+ def flairPicker(field: Field, current: Option[Flair], anyFlair: Boolean = false)(using
+ ctx: Context
): Frag =
frag(
- details(cls := "form-control emoji-details")(
- summary(cls := "button button-metal button-no-upper")(
- label,
- ":",
- nbsp,
- view
+ div(cls := "form-control emoji-details")(
+ div(cls := "emoji-popup-button")(
+ st.select(st.id := id(field), name := field.name, cls := "form-control")(
+ current.map(f => option(value := f, selected := ""))
+ ),
+ img(src := current.fold("")(formHelper.flairSrc(_)))
),
- hidden(field, current.map(_.value)),
div(
- cls := "flair-picker",
+ cls := "flair-picker none",
(!ctx.me.exists(_.isAdmin) && !anyFlair).option(exceptEmojis)
+ )(
+ button(cls := "button button-metal emoji-remove")("clear")
)
- ),
- current.isDefined.option(p:
- button(cls := "button button-red button-thin button-empty text emoji-remove")(trans.site.delete())
)
)
diff --git a/modules/ui/src/main/helper/FormHelper.scala b/modules/ui/src/main/helper/FormHelper.scala
index 546343588e65..a5883a4605c2 100644
--- a/modules/ui/src/main/helper/FormHelper.scala
+++ b/modules/ui/src/main/helper/FormHelper.scala
@@ -6,9 +6,10 @@ import scalatags.text.Builder
import lila.ui.ScalatagsTemplate.*
import scala.util.Try
import play.api.i18n.Lang
+import lila.core.data.SimpleMemo
trait FormHelper:
- self: I18nHelper =>
+ self: I18nHelper & AssetHelper =>
protected def flairApi: lila.core.user.FlairApi
@@ -77,7 +78,7 @@ trait FormHelper:
import java.time.{ ZoneId, ZoneOffset }
import scala.jdk.CollectionConverters.*
- lazy val zones: List[(ZoneOffset, ZoneId)] =
+ private val zones: SimpleMemo[List[(ZoneOffset, ZoneId)]] = SimpleMemo(67.minutes.some): () =>
val now = nowInstant
ZoneId.getAvailableZoneIds.asScala.toList
.flatMap: id =>
@@ -87,6 +88,9 @@ trait FormHelper:
.toList
.sortBy: (offset, zone) =>
(offset, zone.getId)
+
def translatedChoices(using lang: Lang): List[(String, String)] =
- zones.map: (offset, zone) =>
- zone.getId -> s"${zone.getDisplayName(java.time.format.TextStyle.NARROW, lang.locale)} $offset"
+ zones
+ .get()
+ .map: (offset, zone) =>
+ zone.getId -> s"${zone.getDisplayName(java.time.format.TextStyle.NARROW, lang.locale)} $offset"
diff --git a/package.json b/package.json
index ea51ab3d0667..8578cb317bfa 100644
--- a/package.json
+++ b/package.json
@@ -23,9 +23,6 @@
"pnpm": "^9"
},
"//": "NOTE: lint-staged patterns must stay in sync with bin/git-hooks/pre-commit!",
- "lint-staged": {
- "*.{json,scss,ts}": "prettier --write"
- },
"dependencies": {
"@types/lichess": "workspace:*",
"@types/node": "^22.7.4",
@@ -47,6 +44,7 @@
"add-hooks": "git config --add core.hooksPath bin/git-hooks",
"remove-hooks": "git config --unset core.hooksPath bin/git-hooks",
"lint": "eslint --cache",
+ "lint-staged": "lint-staged --config bin/lint-staged.config.mjs",
"journal": "journalctl --user -fu lila -o cat",
"metals": "tail -F .metals/metals.log | stdbuf -oL cut -c 21- | rg -v '(notification for request|handleCancellation)'",
"serverlog": "pnpm journal & pnpm metals",
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 0228a5ddc876..fad90df57414 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -26,7 +26,7 @@ object Dependencies {
val lettuce = "io.lettuce" % "lettuce-core" % "6.4.0.RELEASE"
val nettyTransport =
("io.netty" % s"netty-transport-native-$notifier" % "4.1.114.Final").classifier(s"$os-$arch")
- val lilaSearch = "org.lichess.search" %% "client" % "3.0.1"
+ val lilaSearch = "org.lichess.search" %% "client" % "3.0.2"
val munit = "org.scalameta" %% "munit" % "1.0.2" % Test
val uaparser = "org.uaparser" %% "uap-scala" % "0.18.0"
val apacheText = "org.apache.commons" % "commons-text" % "1.12.0"
diff --git a/project/build.properties b/project/build.properties
index bc7390601f4e..09feeeed5d39 100644
--- a/project/build.properties
+++ b/project/build.properties
@@ -1 +1 @@
-sbt.version=1.10.3
+sbt.version=1.10.4
diff --git a/translation/dest/broadcast/de-DE.xml b/translation/dest/broadcast/de-DE.xml
index 6d203d124fda..a117e424b8a4 100644
--- a/translation/dest/broadcast/de-DE.xml
+++ b/translation/dest/broadcast/de-DE.xml
@@ -52,21 +52,21 @@
In Lichess öffnen
Teams
Bretter
- Übersicht
- Abonnieren, um bei Rundenbeginn benachrichtigt zu werden. Du kannst in deinen Benutzereinstellungen bei Übertragungen zwischen einer Benachrichtigung per Glocke oder Push-Benachrichtigungen wählen.
+ Überblick
+ Abonnieren, um bei Rundenbeginn benachrichtigt zu werden. Du kannst in deinen Benutzereinstellungen für Übertragungen zwischen einer Benachrichtigung per Glocke oder per Push-Benachrichtigung wählen.
Turnierbild hochladen
Noch keine Bretter vorhanden. Diese werden angezeigt, sobald die Partien hochgeladen werden.
- Die Bretter können per Quelle oder via %s geladen werden
+ Die Bretter können per Quelle oder via %s geladen werden
Beginnt nach %s
Diese Übertragung wird in Kürze beginnen.
Die Übertragung hat noch nicht begonnen.
Offizielle Webseite
Rangliste
- Weitere Optionen auf der %s
- Webmaster-Seite
+ Weitere Optionen auf der %s
+ Webmaster-Seite
Eine öffentliche Echtzeit-PGN-Quelle für diese Runde. Wir bieten auch eine %s für eine schnellere und effizientere Synchronisation.
Bette diese Übertragung in deine Webseite ein
- Bette %s in deine Webseite ein
+ Bette %s in deine Webseite ein
Wertungsdifferenz
Partien in diesem Turnier
Punktestand
diff --git a/translation/dest/broadcast/ko-KR.xml b/translation/dest/broadcast/ko-KR.xml
index 0a8c38c65026..99549e98f9c5 100644
--- a/translation/dest/broadcast/ko-KR.xml
+++ b/translation/dest/broadcast/ko-KR.xml
@@ -48,4 +48,25 @@
올해 나이
비레이팅
최근 토너먼트
+ Lichess에서 열기
+ 팀
+ 보드
+ 개요
+ 라운드가 시작될 때 알림을 받으려면 구독하세요. 계정 설정에서 방송을 위한 벨이나 알림 푸시를 토글할 수 있습니다.
+ 토너먼트 사진 업로드
+ 아직 보드가 없습니다. 게임들이 업로드되면 나타납니다.
+ 보드들은 소스나 %s(으)로 로드될 수 있습니다
+ %s 후 시작
+ 방송이 곧 시작됩니다.
+ 아직 방송이 시작을 하지 않았습니다.
+ 공식 웹사이트
+ 순위
+ %s에서 더 많은 정보를 확인하실 수 있습니다
+ 웹마스터 페이지
+ 이 라운드의 공개된, 실시간 PGN 소스 입니다. 보다 더 빠르고 효율적인 동기화를 위해 %s도 제공됩니다.
+ 이 방송을 웹사이트에 삼입하세요
+ %s을(를) 웹사이트에 삼입하세요
+ 레이팅 차이
+ 이 토너먼트의 게임들
+ 점수
diff --git a/translation/dest/broadcast/nb-NO.xml b/translation/dest/broadcast/nb-NO.xml
index 771eae5464f6..8cb05e4083c0 100644
--- a/translation/dest/broadcast/nb-NO.xml
+++ b/translation/dest/broadcast/nb-NO.xml
@@ -49,4 +49,25 @@
Alder i år
Uratet
Nylige turneringer
+ Åpne i Lichess
+ Lag
+ Brett
+ Oversikt
+ Abonner for å bli varslet når hver runde starter. Du kan velge varselform i kontoinnstillingene dine.
+ Last opp bilde for turneringen
+ Ingen brett. De vises når partiene er lastet opp.
+ Brett kan lastes med en kilde eller via %s
+ Starter etter %s
+ Overføringen starter straks.
+ Overføringen har ikke startet.
+ Offisiell nettside
+ Resultatliste
+ Flere alternativer på %s
+ administratorens side
+ En offentlig PGN-kilde i sanntid for denne runden. Vi tilbyr også en %s for raskere og mer effektiv synkronisering.
+ Bygg inn denne overføringen på nettstedet ditt
+ Bygg inn %s på nettstedet ditt
+ Ratingdifferanse
+ Partier i denne turneringen
+ Poengsum
diff --git a/translation/dest/broadcast/ro-RO.xml b/translation/dest/broadcast/ro-RO.xml
index 617a5be8c3b1..582dfc7c2cb2 100644
--- a/translation/dest/broadcast/ro-RO.xml
+++ b/translation/dest/broadcast/ro-RO.xml
@@ -48,4 +48,8 @@
Vârsta în acest an
Fără rating
Turnee recente
+ Deschide în Lichess
+ Echipe
+ Clasament
+ Scor
diff --git a/translation/dest/broadcast/sk-SK.xml b/translation/dest/broadcast/sk-SK.xml
index 765f0bdb5161..a376992810f4 100644
--- a/translation/dest/broadcast/sk-SK.xml
+++ b/translation/dest/broadcast/sk-SK.xml
@@ -51,4 +51,24 @@
Vek tento rok
Bez hodnotenia
Posledné turnaje
+ Otvoriť na Lichess
+ Tímy
+ Šachovnice
+ Prehľad
+ Prihláste sa, aby ste boli informovaní o začiatku každého kola. V nastaveniach účtu môžete prepnúť zvončekové alebo push upozornenia na vysielanie.
+ Nahrať obrázok pre turnaj
+ Zatiaľ žiadne šachovnice. Objavia sa po nahratí partií.
+ Šachovnice možno načítať pomocou zdroja alebo pomocou %s
+ Vysielanie sa začne čoskoro.
+ Vysielanie sa ešte nezačalo.
+ Oficiálna webstránka
+ Poradie
+ Viac možností nájdete na %s
+ stránke tvorcu
+ Verejný zdroj PGN v reálnom čase pre toto kolo. Ponúkame tiež %s na rýchlejšiu a efektívnejšiu synchronizáciu.
+ Vložiť toto vysielanie na webovú stránku
+ Vložiť %s na webovú stránku
+ Ratingový rozdiel
+ Partie tohto turnaja
+ Skóre
diff --git a/translation/dest/broadcast/tr-TR.xml b/translation/dest/broadcast/tr-TR.xml
index 2aad5b2f4f9c..49d619377306 100644
--- a/translation/dest/broadcast/tr-TR.xml
+++ b/translation/dest/broadcast/tr-TR.xml
@@ -49,4 +49,24 @@
Bu yılki yaşı
Derecelendirilmemiş
Son Turnuvalar
+ Lichess\'te aç
+ Takımlar
+ Tahtalar
+ Genel Bakış
+ Tur başladığında bildirim almak için abone olun. Hesap tercihlerinizden anlık ya da çan bildirimi tercihinizi hesap tercihlerinizden belirleyebilirsiniz.
+ Turnuva görseli yükleyin
+ Henüz tahta bulunmamaktadır. Oyunlar yüklendikçe tahtalar ortaya çıkacaktır.
+ Tahtalar bir kaynaktan ya da %sndan yüklenebilir
+ %s\'ten sonra başlar
+ Yayın az sonra başlayacak.
+ Yayın henüz başlamadı.
+ Resmî site
+ Sıralamalar
+ %snda daha fazla seçenek
+ Bu turun açık, gerçek zamanlı PGN kaynağı. Daha hızlı ve verimli senkronizasyon için %s\'ımız da bulunmaktadır.
+ İnternet sitenizde bu yayını gömülü paylaşın
+ %su İnternet sitenizde gömülü paylaşın
+ Puan farkı
+ Bu turnuvadaki maçlar
+ Skor
diff --git a/translation/dest/challenge/is-IS.xml b/translation/dest/challenge/is-IS.xml
index 3ea04e700dfa..f430e122a3a3 100644
--- a/translation/dest/challenge/is-IS.xml
+++ b/translation/dest/challenge/is-IS.xml
@@ -1,2 +1,19 @@
-
+
+ Áskoranir: %1$s
+ Skráðu þig inn til að senda áskorarnir þessa notanda.
+ %s tekur ekki við áskorunum.
+ Ekki er hægt að skora á vegna tímabundinna skákstiga í %s.
+ %s samþykkir eingöngu áskoranir frá vinum.
+ Ég tek ekki við áskorunum í augnablikinu.
+ Þetta er ekki heppilegur tími fyrir mig, vinsamlegast spyrðu aftur síðar.
+ Þessar tímaskorður er of hraðar fyrir mig, vinsamlegast skoraðu aftur á með hægari leik.
+ Þessar tímaskorður er of hægar fyrir mig, vinsamlegast skoraðu aftur á með hraðari leik.
+ Ég samþykki ekki áskoranir með þessum tímaskorðum.
+ Vinsamlegast sendu mér áskorun með stigagjöf í staðinn.
+ Vinsamlegast sendu mér áskorun til æfingar í staðinn.
+ Ég samþykki ekki afbrigðilegar áskoranir í augnablikinu.
+ Ég er ekki til í að spila þetta afbrigði í augnablikinu.
+ Ég samþykki ekki áskoranir þjarka.
+ Ég samþykki eingöngu áskoranir þjarka.
+
diff --git a/translation/dest/faq/fa-IR.xml b/translation/dest/faq/fa-IR.xml
index 4e56968f504c..98f81d418612 100644
--- a/translation/dest/faq/fa-IR.xml
+++ b/translation/dest/faq/fa-IR.xml
@@ -61,7 +61,7 @@
آموزش Lichess
کیش و مات شدن %s در مقابل استاد بینالمللی اریک روزن را تماشا کنید.
تکرار سه گانه
- اگر یک وضعیت سه بار رخ دهد، بازیکنان میتوانند با %1$s، ادعای تساوی کنند. Lichess قانونهای رسمی فیده را که در ماده 9.2 %2$s آمده را، اجرا میکند.
+ اگر یک وضعیت سه بار رخ دهد، بازیکنان میتوانند با %1$s، ادعای تساوی کنند. Lichess قانونهای رسمی فیده را که در ماده 9.2 %2$s آمده، اجرا میکند.
تکرار سه گانه
ما حرکات را تکرار نکردیم. چرا بازی همچنان با تکرار مساوی می شد؟
تکرار سهگانه درباره %1$s تکراری است، نه حرکتها. لازم نیست که تکرار، پیاپی باشد.
diff --git a/translation/dest/features/zh-TW.xml b/translation/dest/features/zh-TW.xml
index 072d8f6338ab..063978802f90 100644
--- a/translation/dest/features/zh-TW.xml
+++ b/translation/dest/features/zh-TW.xml
@@ -3,31 +3,31 @@
零廣告與追蹤
可以預先移動的通訊棋局
基本西洋棋與其他 %s
- %s 深沉分析
+ %s 深度分析
- 每天 %s 局
- 棋盤編輯器並以 %s 分析
+ 帶有 %s 的棋盤編輯器和棋局分析
雲端引擎分析
研究(可分享的永久性分析)
- 對局洞察(對棋局更加的深刻分析)
+ 對局洞察(對您棋局的深入分析)
所有基礎西洋棋課程
- 從他人棋局中生成謎題以提升西洋棋戰術
- %1$s(亦可分析%2$s)
+ 來自用戶棋局的戰術謎題
+ %1$s(亦可探索%2$s的)
個人開局瀏覽器
- 全域開局瀏覽器(%s 個棋局!)
- 7 子殘局分析器
- 下載/上傳任何 PGN 棋譜
+ 全球開局瀏覽器(%s 個棋局!)
+ 7 子殘局資料庫
+ 以 PGN 格式下載/上傳任何棋局
部落格、論壇、團隊、直播、聯絡、朋友、挑戰
- 亮暗主題、自訂棋盤、棋子、與背景
+ 亮/暗主題,自訂棋盤、棋子與背景
UltraBullet、Bullet、Blitz、快棋、經典、通訊棋
- 所有將來開發的新功能!
+ 所有持續推出的功能,永遠!
iPhones & Android 裝置與橫向模式支援
- 支援 Lichess
- 捐款給 Lichess 取得超酷的贊助者圖示!
+ 支持 Lichess
+ 贊助 Lichess 並獲得一個炫酷的贊助者圖標!
是的,兩種帳號皆有相同的功能!
- 我們相信每個人都直得擁有最好的,所以:
- 所有功能都完全免費給大家使用!
+ 我們相信每個人都值得擁有最好的,所以:
+ 所有功能都完全免費給大家使用,永遠!
如果你喜歡 Lichess,
- 考慮成為贊助者!
+ 透過成為贊助者以支持我們!
diff --git a/translation/dest/oauthScope/is-IS.xml b/translation/dest/oauthScope/is-IS.xml
index 3ea04e700dfa..c025d39b9894 100644
--- a/translation/dest/oauthScope/is-IS.xml
+++ b/translation/dest/oauthScope/is-IS.xml
@@ -1,2 +1,4 @@
-
+
+ Leiktu skákir með forritaskil þjarka
+
diff --git a/translation/dest/preferences/is-IS.xml b/translation/dest/preferences/is-IS.xml
index 8a277f82b2bd..d2e26776642f 100644
--- a/translation/dest/preferences/is-IS.xml
+++ b/translation/dest/preferences/is-IS.xml
@@ -23,7 +23,7 @@
Hvernig hreyfirðu menn?
Veldu tvo reiti
Dragðu taflmann
- Annað hvort
+ Annaðhvort
Fyrirframleikir (leiknir meðan anstæðingur á leik)
Leikir teknir upp (með leyfi mótherja)
Einungis í stigalausum leikjum
@@ -41,4 +41,5 @@
Leika með lyklaborðinu
Segja \"Good game, well played\" sjálfkrafa eftir tapi eða jafntefli
Stillingar þínar voru vistaðar
+ Áskoranir
diff --git a/translation/dest/site/fa-IR.xml b/translation/dest/site/fa-IR.xml
index 19637badde20..b07f6d0a49d1 100644
--- a/translation/dest/site/fa-IR.xml
+++ b/translation/dest/site/fa-IR.xml
@@ -2,7 +2,7 @@
بازی با دوستان
بازی با رایانه
- برای دعوت کردن حریف این لینک را برای او بفرستید
+ برای دعوت کسی به بازی، این وبنشانی را دهید
پایان بازی
انتطار برای حریف
یا اجازه دهید حریف شما این QR کد را پویش کند
@@ -163,8 +163,8 @@
- %1$s ریتینگ در %2$s بازی
- - %s بازی مورد علاقه
- - %s بازی مورد علاقه
+ - %s نشانک
+ - %s نشانک
نمایش در اندازه کامل
خروج
@@ -340,7 +340,7 @@
پیشنهاد پس گرفتن حرکت پذیرفته شد
پیشنهاد پس گرفتن حرکت لغو شد
حریف پیشنهاد پس گرفتن حرکت می دهد
- نشان گذاری بازی
+ نشانکگذاری
مسابقه
مسابقات
مجموع امتیازات مسابقات
@@ -385,7 +385,7 @@
باید در تیم %s باشید
شما در تیم %s نیستید
بازگشت به بازی
- کارساز برخط و رایگان شطرنج. با میانایی روان شطرنج بازی کنید. بدون نامنویسی، بدون تبلیغ، بدون نیاز به افزونه. با رایانه، دوستان یا حریفان تصادفی شطرنج بازی کنید.
+ کارساز برخط و رایگان شطرنج. با میانایی روان، شطرنج بازی کنید. بدون نامنویسی، بدون تبلیغ، بدون نیاز به افزونه. با رایانه، دوستان یا حریفان تصادفی شطرنج بازی کنید.
%1$s به تیم %2$s پیوست
%1$s تیم %2$s را ایجاد کرد
پخش را آغازید
diff --git a/translation/dest/site/is-IS.xml b/translation/dest/site/is-IS.xml
index 391ffc35d70c..9591f7f50a1d 100644
--- a/translation/dest/site/is-IS.xml
+++ b/translation/dest/site/is-IS.xml
@@ -27,7 +27,7 @@
Þú teflir með hvítu mennina
Þú teflir með svörtu mennina
Þú átt leik!
- Svindl greint
+ Svindl greindist
Kóngur á miðborðinu
Þrjár skákir
Kapphlaupi lokið
@@ -89,8 +89,8 @@
Bestu skákir
OTB skákir %1$s+ FIDE-stiga skákmanna frá %2$s til %3$s
- - Mát eftir %s hálfleik
- - Mát eftir %s hálfleikum
+ - Mát í %s hálfleik
+ - Mát í %s hálfleikum
Engin skák fannst
Hámarks dýpt náð!
@@ -261,8 +261,8 @@
Sérsniðin staða
Ótarkmarkað
Tegund
- Venjuleg
- Stigaleikur
+ Æfingaskák
+ Stigaskák
Æfingamót
Með stigagjöf
Þessi skák telur til stiga
@@ -361,7 +361,7 @@
%1$s stofnaði liðið %2$s
byrjaði að streyma
%s byrjaði streymi
- Meðal skákstig
+ Meðalskákstig
Staðsetning
Sía skákir
Hreinsa
@@ -398,8 +398,8 @@
Fylgja
Fylgir
Affylgja
- Fylgja %s
- Affylgja %s
+ Fylgjast með %s
+ Hætta að fylgjast með %s
Blokkera
Blokkaður
Afblokka
@@ -492,6 +492,7 @@
Skipta sjálfkrafa yfir í næstu skák eftir leik
Skipta sjálfkrafa
Þrautir
+ Tengdir þjarkar
Nafn
Lýsing
Texti sem eingöngu liðsfélagar sjá. Ef texti er til staðar kemur hann í stað opinberu lýsingarinnar í sýn liðsfélaga.
diff --git a/translation/dest/site/ko-KR.xml b/translation/dest/site/ko-KR.xml
index d5a9b76745db..a773745523dd 100644
--- a/translation/dest/site/ko-KR.xml
+++ b/translation/dest/site/ko-KR.xml
@@ -952,4 +952,5 @@ FEN 포지션을 생성하기 위해 %s를 사용할 수 있습니다.
Lichess는 비영리 기구이며 완전한 무료/자유 오픈소스 소프트웨어입니다.
모든 운영 비용, 개발, 컨텐츠 조달은 전적으로 사용자들의 기부로 이루어집니다.
지금은 여기에 볼 것이 없습니다.
+ 통계
diff --git a/translation/dest/site/nb-NO.xml b/translation/dest/site/nb-NO.xml
index f7db031ebdd2..cd797170024b 100644
--- a/translation/dest/site/nb-NO.xml
+++ b/translation/dest/site/nb-NO.xml
@@ -994,4 +994,5 @@ La feltet stå tomt for å begynne partiene fra den normale utgangsstillingen.
Lichess er en ideell forening, basert på fri programvare med åpen kildekode.
Alle kostnader for drift, utvikling og innhold finansieres utelukkende av brukerbidrag.
Ingenting her for nå.
+ Statistikk
diff --git a/translation/dest/site/ro-RO.xml b/translation/dest/site/ro-RO.xml
index 434f0218512c..6806b89f65db 100644
--- a/translation/dest/site/ro-RO.xml
+++ b/translation/dest/site/ro-RO.xml
@@ -1036,4 +1036,5 @@ Lăsați gol pentru a începe jocurile din poziția inițială normală.Lichess este o asociație non-profit și un software gratuit și open-source.
Toate costurile de operare și de dezvoltare sunt finanțate doar din donațiile utilizatorilor.
Nimic de văzut aici momentan.
+ Statistici
diff --git a/translation/dest/site/sk-SK.xml b/translation/dest/site/sk-SK.xml
index 2a948c72515d..7e6d507ae156 100644
--- a/translation/dest/site/sk-SK.xml
+++ b/translation/dest/site/sk-SK.xml
@@ -1077,4 +1077,5 @@ Nechajte prázdne pre začatie partií zo základnej pozície!
Ukázať všetko
Lichess je bezplatný a úplne slobodný/nezávislý softvér s otvoreným zdrojovým kódom. Všetky prevádzkové náklady, vývoj a obsah sú financované výlučne z darov používateľov.
Momentálne tu nie je nič k zobrazeniu.
+ Štatistiky
diff --git a/translation/dest/site/tr-TR.xml b/translation/dest/site/tr-TR.xml
index e5b666901170..40cef6bbab2d 100644
--- a/translation/dest/site/tr-TR.xml
+++ b/translation/dest/site/tr-TR.xml
@@ -992,4 +992,5 @@ Başlangıç pozisyonundan oynamak için boş bırakınız.
Bana her şeyi göster
Lichess bir yardım kuruluşudur ve tamamen özgür/açık kaynak kodlu bir yazılımdır. Tüm işletme maliyetleri, geliştirmeler ve içerikler yalnızca kullanıcı bağışları ile finanse edilmektedir.
Şu anda görülebilecek bir şey yok.
+ İstatistikler
diff --git a/ui/.build/readme b/ui/.build/readme
index 4159e1aeff02..094de78d3129 100644
--- a/ui/.build/readme
+++ b/ui/.build/readme
@@ -1,36 +1,39 @@
-ui/build was lovingly crafted from a single block of wood as a personal gift to you.
+Usage:
+ ui/build # are top level directories in ui
+ # if no packages are specified, all will be processed
+ # multiple short options can be preceded by a single dash
- ./build # packages are top level directories in ui
- one letter options can be consolidated after a single dash (e.g. -cdw)
- if no packages are specified, all will be processed
+Recommended:
+ ui/build -cdw # clean, build debug, and watch for changes with clean rebuilds
Options:
-h, --help show this help and exit
-w, --watch build and watch for changes
- -c, --clean-build build fresh artifacts
+ -c, --clean-build clean all non-i18n build artifacts and build fresh
-p, --prod build minified assets (prod builds)
-n, --no-install don't run pnpm install
-d, --debug build assets with site.debug = true
-l, --log= monkey patch console log functions in javascript manifest to POST log messages to
or localhost:8666 (default). if used with --watch, the ui/build process
will listen for http on 8666 and display received json as 'web' in build logs
- --clean clean all build artifacts, including translation/js, and exit
- --update update ui/build's node_modules
+ --update update ui/.build/node_modules with pnpm install
--no-color don't use color in logs
--no-time don't log the time
--no-context don't log the context
- --tsc run tsc, any of [--tsc, --sass, --esbuild, --copies] will disable the others
+
+Exclusive Options: (any of these will disable other functions)
+ --clean clean all build artifacts, including i18n/translation, and exit
+ --tsc run tsc on {package}/tsconfig.json and dependencies
--sass run sass on {package}/css/build/*.scss and dependencies
--esbuild run esbuild (given in {package}/package.json/lichess/bundles array)
- --copies run copies (given in {package}/package.json/lichess/sync objects)
+ --sync run sync copies (given in {package}/package.json/lichess/sync objects)
+ --i18n build @types/lichess/i18n.d.ts and translation/js files
Examples:
- ./build -w # build and watch for changes <- this is the most common use case
- ./build -wc # clean build, watch <- recommended for maintainers
- ./build -w --log=/path # build, watch, and patch js console POST to ${location.origin}/path.
- # ui/build listens on 8666, displays received json as 'web' over stdout
./build -np # no pnpm install, build minified
./build analyse site msg # build analyse, site, and msg packages (as opposed to everything)
./build -w dasher chart # watch mode for dasher and chart packages
./build --tsc -w # watch mode but type checking only
./build --sass msg notify # build css only for msg and notify packages
+ ./build -w -l=/path # build, watch, and patch js console with POST to ${location.origin}/path.
+ # ui/build listens on 8666, displays received json as 'web' over stdout
diff --git a/ui/.build/src/algo.ts b/ui/.build/src/algo.ts
new file mode 120000
index 000000000000..22cd5bb3aaf4
--- /dev/null
+++ b/ui/.build/src/algo.ts
@@ -0,0 +1 @@
+../../common/src/algo.ts
\ No newline at end of file
diff --git a/ui/.build/src/build.ts b/ui/.build/src/build.ts
index 62b32e256531..b43ecdb5d76b 100644
--- a/ui/.build/src/build.ts
+++ b/ui/.build/src/build.ts
@@ -5,7 +5,7 @@ import { parsePackages } from './parse.ts';
import { tsc, stopTsc } from './tsc.ts';
import { sass, stopSass } from './sass.ts';
import { esbuild, stopEsbuild } from './esbuild.ts';
-import { copies, stopCopies } from './copies.ts';
+import { sync, stopSync } from './sync.ts';
import { monitor, stopMonitor } from './monitor.ts';
import { writeManifest } from './manifest.ts';
import { clean } from './clean.ts';
@@ -38,7 +38,7 @@ export async function build(pkgs: string[]): Promise {
fs.promises.mkdir(env.buildTempDir),
]);
- await Promise.all([sass(), copies(), i18n()]);
+ await Promise.all([sass(), sync(), i18n()]);
await esbuild(tsc());
monitor(pkgs);
}
@@ -47,7 +47,7 @@ export async function stop(): Promise {
stopMonitor();
stopSass();
stopTsc();
- stopCopies();
+ stopSync();
stopI18n();
await stopEsbuild();
}
@@ -71,8 +71,6 @@ export function prePackage(pkg: Package | undefined): void {
});
}
-export const quantize = (n?: number, factor = 2000) => Math.floor((n ?? 0) / factor) * factor;
-
function depsOne(pkgName: string): Package[] {
const collect = (dep: string): string[] => [...(env.deps.get(dep) || []).flatMap(d => collect(d)), dep];
return unique(collect(pkgName).map(name => env.packages.get(name)));
diff --git a/ui/.build/src/i18n.ts b/ui/.build/src/i18n.ts
index f8fd0f3d860a..0da4a106a2f7 100644
--- a/ui/.build/src/i18n.ts
+++ b/ui/.build/src/i18n.ts
@@ -4,7 +4,7 @@ import { XMLParser } from 'fast-xml-parser';
import { env, colors as c } from './main.ts';
import { globArray } from './parse.ts';
import { i18nManifest } from './manifest.ts';
-import { quantize } from './build.ts';
+import { quantize, zip } from './algo.ts';
import { transform } from 'esbuild';
type Plural = { [key in 'zero' | 'one' | 'two' | 'few' | 'many' | 'other']?: string };
@@ -192,15 +192,6 @@ function parseXml(xmlData: string): Map {
return new Map([...i18nMap.entries()].sort(([a], [b]) => a.localeCompare(b)));
}
-function zip(arr1: T[], arr2: U[]): [T, U][] {
- const length = Math.min(arr1.length, arr2.length);
- const result: [T, U][] = [];
- for (let i = 0; i < length; i++) {
- result.push([arr1[i], arr2[i]]);
- }
- return result;
-}
-
async function min(js: string): Promise {
return (await transform(js, { minify: true, loader: 'js' })).code;
}
diff --git a/ui/.build/src/main.ts b/ui/.build/src/main.ts
index 9089eb806e42..0e62cd6a1688 100644
--- a/ui/.build/src/main.ts
+++ b/ui/.build/src/main.ts
@@ -10,7 +10,7 @@ const args: Record = {
'--tsc': '',
'--sass': '',
'--esbuild': '',
- '--copies': '',
+ '--sync': '',
'--i18n': '',
'--no-color': '',
'--no-time': '',
@@ -48,13 +48,13 @@ export function main(): void {
.filter(x => x.startsWith('--') && !Object.keys(args).includes(x.split('=')[0]))
.forEach(arg => env.exit(`Unknown argument '${arg}'`));
- if (['--tsc', '--sass', '--esbuild', '--copies', '--i18n'].filter(x => argv.includes(x)).length) {
+ if (['--tsc', '--sass', '--esbuild', '--sync', '--i18n'].filter(x => argv.includes(x)).length) {
// including one or more of these disables the others
if (!argv.includes('--sass')) env.exitCode.set('sass', false);
if (!argv.includes('--tsc')) env.exitCode.set('tsc', false);
if (!argv.includes('--esbuild')) env.exitCode.set('esbuild', false);
env.i18n = argv.includes('--i18n');
- env.copies = argv.includes('--copies');
+ env.sync = argv.includes('--sync');
}
if (argv.includes('--no-color')) env.color = undefined;
if (argv.includes('--no-time')) env.logTime = false;
@@ -134,7 +134,7 @@ class Env {
remoteLog: string | boolean = false;
rgb = false;
install = true;
- copies = true;
+ sync = true;
i18n = true;
exitCode: Map = new Map();
startTime: number | undefined = Date.now();
@@ -147,7 +147,6 @@ class Env {
esbuild: 'blue',
};
- constructor() {}
get sass(): boolean {
return this.exitCode.get('sass') !== false;
}
@@ -256,9 +255,7 @@ class Env {
this.log(
`${code === 0 ? 'Done' : colors.red('Failed')}` +
(this.watch ? ` - ${colors.grey('Watching')}...` : ''),
- {
- ctx: ctx,
- },
+ { ctx },
);
if (allDone) {
if (!err) postBuild();
@@ -266,9 +263,7 @@ class Env {
this.log(`Done in ${colors.green((Date.now() - this.startTime) / 1000 + '')}s`);
this.startTime = undefined; // it's pointless to time subsequent builds, they are too fast
}
- if (!env.watch && err) {
- process.exitCode = err;
- }
+ if (!env.watch && err) process.exitCode = err;
}
}
diff --git a/ui/.build/src/manifest.ts b/ui/.build/src/manifest.ts
index f7879044dfda..627a02c213c4 100644
--- a/ui/.build/src/manifest.ts
+++ b/ui/.build/src/manifest.ts
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
import es from 'esbuild';
import { env, colors as c, warnMark } from './main.ts';
import { globArray, globArrays } from './parse.ts';
-import { isUnmanagedAsset } from './copies.ts';
+import { isUnmanagedAsset } from './sync.ts';
import { allSources } from './sass.ts';
import { jsLogger } from './console.ts';
diff --git a/ui/.build/src/copies.ts b/ui/.build/src/sync.ts
similarity index 95%
rename from ui/.build/src/copies.ts
rename to ui/.build/src/sync.ts
index 19883033f054..aeebb1de5d18 100644
--- a/ui/.build/src/copies.ts
+++ b/ui/.build/src/sync.ts
@@ -3,20 +3,20 @@ import path from 'node:path';
import { globArray, globArrays } from './parse.ts';
import { hashedManifest, writeManifest } from './manifest.ts';
import { type Sync, env, errorMark, colors as c } from './main.ts';
-import { quantize } from './build.ts';
+import { quantize } from './algo.ts';
const syncWatch: fs.FSWatcher[] = [];
let watchTimeout: NodeJS.Timeout | undefined;
-export function stopCopies(): void {
+export function stopSync(): void {
clearTimeout(watchTimeout);
watchTimeout = undefined;
for (const watcher of syncWatch) watcher.close();
syncWatch.length = 0;
}
-export async function copies(): Promise {
- if (!env.copies) return;
+export async function sync(): Promise {
+ if (!env.sync) return;
const watched = new Map();
const updated = new Set();
diff --git a/ui/.build/src/tscWorker.ts b/ui/.build/src/tscWorker.ts
index 6cf516e83e2d..6469f2dc15b7 100644
--- a/ui/.build/src/tscWorker.ts
+++ b/ui/.build/src/tscWorker.ts
@@ -16,7 +16,6 @@ export interface Message {
export interface ErrorMessage extends Message {
type: 'error';
data: {
- project: string;
code: number;
text: string;
file: string;
diff --git a/ui/analyse/src/ctrl.ts b/ui/analyse/src/ctrl.ts
index 1b23f0ec1b88..8e348b2e3a47 100644
--- a/ui/analyse/src/ctrl.ts
+++ b/ui/analyse/src/ctrl.ts
@@ -54,6 +54,7 @@ import ForecastCtrl from './forecast/forecastCtrl';
import { ArrowKey, KeyboardMove, ctrl as makeKeyboardMove } from 'keyboardMove';
import * as control from './control';
import { PgnError } from 'chessops/pgn';
+import { confirm } from 'common/dialog';
export default class AnalyseCtrl {
data: AnalyseData;
@@ -611,18 +612,18 @@ export default class AnalyseCtrl {
this.withCg(cg => cg.playPremove());
}
- deleteNode(path: Tree.Path): void {
+ async deleteNode(path: Tree.Path): Promise {
const node = this.tree.nodeAtPath(path);
if (!node) return;
const count = treeOps.countChildrenAndComments(node);
if (
(count.nodes >= 10 || count.comments > 0) &&
- !confirm(
+ !(await confirm(
'Delete ' +
plural('move', count.nodes) +
(count.comments ? ' and ' + plural('comment', count.comments) : '') +
'?',
- )
+ ))
)
return;
this.tree.deleteNodeAt(path);
diff --git a/ui/analyse/src/serverSideUnderboard.ts b/ui/analyse/src/serverSideUnderboard.ts
index 83e7029de700..2399ded3d391 100644
--- a/ui/analyse/src/serverSideUnderboard.ts
+++ b/ui/analyse/src/serverSideUnderboard.ts
@@ -5,7 +5,7 @@ import { url as xhrUrl, textRaw as xhrTextRaw } from 'common/xhr';
import { AnalyseData } from './interfaces';
import { ChartGame, AcplChart } from 'chart';
import { stockfishName, spinnerHtml } from 'common/spinner';
-import { domDialog } from 'common/dialog';
+import { alert, confirm, domDialog } from 'common/dialog';
import { FEN } from 'chessground/types';
import { escapeHtml } from 'common';
import { storage } from 'common/storage';
@@ -120,15 +120,16 @@ export default function (element: HTMLElement, ctrl: AnalyseCtrl) {
if (!data.analysis) {
$panels.find('form.future-game-analysis').on('submit', function (this: HTMLFormElement) {
if ($(this).hasClass('must-login')) {
- if (confirm(i18n.site.youNeedAnAccountToDoThat))
- location.href = '/login?referrer=' + window.location.pathname;
+ confirm(i18n.site.youNeedAnAccountToDoThat, i18n.site.signIn, i18n.site.cancel).then(yes => {
+ if (yes) location.href = '/login?referrer=' + window.location.pathname;
+ });
return false;
}
xhrTextRaw(this.action, { method: this.method }).then(res => {
if (res.ok) startAdvantageChart();
else
- res.text().then(t => {
- if (t && !t.startsWith('')) alert(t);
+ res.text().then(async t => {
+ if (t && !t.startsWith('')) await alert(t);
site.reload();
});
});
diff --git a/ui/analyse/src/study/chapterEditForm.ts b/ui/analyse/src/study/chapterEditForm.ts
index 46e116995d7c..8411b3c86ce7 100644
--- a/ui/analyse/src/study/chapterEditForm.ts
+++ b/ui/analyse/src/study/chapterEditForm.ts
@@ -4,7 +4,7 @@ import { spinnerVdom as spinner } from 'common/spinner';
import { option, emptyRedButton } from '../view/util';
import { ChapterMode, EditChapterData, Orientation, StudyChapterConfig, ChapterPreview } from './interfaces';
import { defined, prop } from 'common';
-import { snabDialog } from 'common/dialog';
+import { confirm, snabDialog } from 'common/dialog';
import { h, VNode } from 'snabbdom';
import { Redraw } from '../interfaces';
import { StudySocketSend } from '../socket';
@@ -142,8 +142,8 @@ function viewLoaded(ctrl: StudyChapterEditForm, data: StudyChapterConfig): VNode
{
hook: bind(
'click',
- () => {
- if (confirm(i18n.study.clearAllCommentsInThisChapter)) ctrl.clearAnnotations(data.id);
+ async () => {
+ if (await confirm(i18n.study.clearAllCommentsInThisChapter)) ctrl.clearAnnotations(data.id);
},
ctrl.redraw,
),
@@ -156,8 +156,8 @@ function viewLoaded(ctrl: StudyChapterEditForm, data: StudyChapterConfig): VNode
{
hook: bind(
'click',
- () => {
- if (confirm(i18n.study.clearVariations)) ctrl.clearVariations(data.id);
+ async () => {
+ if (await confirm(i18n.study.clearVariations)) ctrl.clearVariations(data.id);
},
ctrl.redraw,
),
@@ -172,8 +172,8 @@ function viewLoaded(ctrl: StudyChapterEditForm, data: StudyChapterConfig): VNode
{
hook: bind(
'click',
- () => {
- if (confirm(i18n.study.deleteThisChapter)) ctrl.delete(data.id);
+ async () => {
+ if (await confirm(i18n.study.deleteThisChapter)) ctrl.delete(data.id);
},
ctrl.redraw,
),
diff --git a/ui/analyse/src/study/description.ts b/ui/analyse/src/study/description.ts
index 34e4021ae972..d70c33a477c6 100644
--- a/ui/analyse/src/study/description.ts
+++ b/ui/analyse/src/study/description.ts
@@ -3,6 +3,7 @@ import * as licon from 'common/licon';
import { bind, onInsert, looseH as h } from 'common/snabbdom';
import { richHTML } from 'common/richText';
import StudyCtrl from './studyCtrl';
+import { confirm } from 'common/dialog';
export type Save = (t: string) => void;
@@ -46,8 +47,8 @@ export function view(study: StudyCtrl, chapter: boolean): VNode | undefined {
}),
h('a', {
attrs: { 'data-icon': licon.Trash, title: 'Delete' },
- hook: bind('click', () => {
- if (confirm('Delete permanent description?')) desc.save('');
+ hook: bind('click', async () => {
+ if (await confirm('Delete permanent description?')) desc.save('');
}),
}),
]),
diff --git a/ui/analyse/src/study/studyChapters.ts b/ui/analyse/src/study/studyChapters.ts
index 4e2f3abc61cd..00eab59eaa39 100644
--- a/ui/analyse/src/study/studyChapters.ts
+++ b/ui/analyse/src/study/studyChapters.ts
@@ -27,6 +27,7 @@ import { fenColor } from 'common/miniBoard';
import { initialFen } from 'chess';
import type Sortable from 'sortablejs';
import { pubsub } from 'common/pubsub';
+import { alert } from 'common/dialog';
/* read-only interface for external use */
export class StudyChapters {
diff --git a/ui/analyse/src/study/studyComments.ts b/ui/analyse/src/study/studyComments.ts
index d0c37481d186..ba38f5fbf5d2 100644
--- a/ui/analyse/src/study/studyComments.ts
+++ b/ui/analyse/src/study/studyComments.ts
@@ -5,6 +5,7 @@ import { richHTML } from 'common/richText';
import AnalyseCtrl from '../ctrl';
import { nodeFullName } from '../view/util';
import StudyCtrl from './studyCtrl';
+import { confirm } from 'common/dialog';
export type AuthorObj = {
id: string;
@@ -40,14 +41,12 @@ export function currentComments(ctrl: AnalyseCtrl, includingMine: boolean): VNod
study.members.canContribute() && study.vm.mode.write
? h('a.edit', {
attrs: { 'data-icon': licon.Trash, title: 'Delete' },
- hook: bind(
- 'click',
- () => {
- if (confirm('Delete ' + authorText(by) + "'s comment?"))
- study.commentForm.delete(chapter.id, ctrl.path, comment.id);
- },
- ctrl.redraw,
- ),
+ hook: bind('click', async () => {
+ if (await confirm('Delete ' + authorText(by) + "'s comment?")) {
+ study.commentForm.delete(chapter.id, ctrl.path, comment.id);
+ ctrl.redraw();
+ }
+ }),
})
: null,
authorDom(by),
diff --git a/ui/analyse/src/study/studyCtrl.ts b/ui/analyse/src/study/studyCtrl.ts
index d791c700cb02..e0624a91649a 100644
--- a/ui/analyse/src/study/studyCtrl.ts
+++ b/ui/analyse/src/study/studyCtrl.ts
@@ -51,6 +51,7 @@ import { GamebookOverride } from './gamebook/interfaces';
import { EvalHitMulti, EvalHitMultiArray } from '../interfaces';
import { MultiCloudEval } from './multiCloudEval';
import { pubsub } from 'common/pubsub';
+import { alert } from 'common/dialog';
interface Handlers {
path(d: WithWhoAndPos): void;
diff --git a/ui/analyse/src/study/studyForm.ts b/ui/analyse/src/study/studyForm.ts
index 1e493690690d..1202890d097b 100644
--- a/ui/analyse/src/study/studyForm.ts
+++ b/ui/analyse/src/study/studyForm.ts
@@ -1,7 +1,7 @@
import { VNode } from 'snabbdom';
import * as licon from 'common/licon';
import { prop } from 'common';
-import { snabDialog } from 'common/dialog';
+import { confirm, prompt, snabDialog } from 'common/dialog';
import flairPickerLoader from 'bits/flairPicker';
import { bindSubmit, bindNonPassive, onInsert, looseH as h } from 'common/snabbdom';
import { emptyRedButton } from '../view/util';
@@ -123,29 +123,28 @@ export function view(ctrl: StudyForm): VNode {
[
h('div.form-split.flair-and-name' + (ctrl.relay ? '.none' : ''), [
h('div.form-group', [
- h('label.form-label', 'Flair ▼'),
+ h('label.form-label', 'Flair'),
h(
- 'details.form-control.emoji-details',
+ 'div.form-control.emoji-details',
{
hook: onInsert(el => flairPickerLoader(el)),
},
[
- h('summary.button.button-metal.button-no-upper', [
- h('span.flair-container', [
- h('img.uflair', {
- attrs: { src: data.flair ? site.asset.flairSrc(data.flair) : '' },
- }),
- ]),
+ h('div.emoji-popup-button', [
+ h(
+ 'select#study-flair.form-control',
+ { attrs: { name: 'flair' } },
+ data.flair && h('option', { attrs: { value: data.flair, selected: true } }),
+ ),
+ h('img', { attrs: { src: data.flair ? site.asset.flairSrc(data.flair) : '' } }),
]),
- h('input#study-flair', {
- attrs: { type: 'hidden', name: 'flair', value: data.flair || '' },
- }),
- h('div.flair-picker', {
- attrs: { 'data-except-emojis': 'activity.lichess' },
- }),
+ h(
+ 'div.flair-picker.none',
+ { attrs: { 'data-except-emojis': 'activity.lichess' } },
+ h(removeEmojiButton, 'clear'),
+ ),
],
),
- data.flair && h(removeEmojiButton, 'Delete'),
]),
h('div.form-group', [
h('label.form-label', { attrs: { for: 'study-name' } }, i18n.site.name),
@@ -254,11 +253,14 @@ export function view(ctrl: StudyForm): VNode {
'form',
{
attrs: { action: '/study/' + data.id + '/delete', method: 'post' },
- hook: bindNonPassive(
- 'submit',
- _ =>
- isNew || prompt(i18n.study.confirmDeleteStudy(data.name))?.trim() === data.name.trim(),
- ),
+ hook: bindNonPassive('submit', e => {
+ if (isNew) return;
+
+ e.preventDefault();
+ prompt(i18n.study.confirmDeleteStudy(data.name)).then(userInput => {
+ if (userInput?.trim() === data.name.trim()) (e.target as HTMLFormElement).submit();
+ });
+ }),
},
[h(emptyRedButton, isNew ? i18n.site.cancel : i18n.study.deleteStudy)],
),
@@ -267,7 +269,12 @@ export function view(ctrl: StudyForm): VNode {
'form',
{
attrs: { action: '/study/' + data.id + '/clear-chat', method: 'post' },
- hook: bindNonPassive('submit', _ => confirm(i18n.study.deleteTheStudyChatHistory)),
+ hook: bindNonPassive('submit', e => {
+ e.preventDefault();
+ confirm(i18n.study.deleteTheStudyChatHistory).then(yes => {
+ if (yes) (e.target as HTMLFormElement).submit();
+ });
+ }),
},
[h(emptyRedButton, i18n.study.clearChat)],
),
@@ -280,4 +287,4 @@ export function view(ctrl: StudyForm): VNode {
});
}
-const removeEmojiButton = emptyRedButton + '.text.emoji-remove';
+const removeEmojiButton = 'button.button.button-metal.emoji-remove';
diff --git a/ui/bits/src/bits.account.ts b/ui/bits/src/bits.account.ts
index fdfea85c721e..bdd6c5ffbbea 100644
--- a/ui/bits/src/bits.account.ts
+++ b/ui/bits/src/bits.account.ts
@@ -3,6 +3,8 @@ import * as xhr from 'common/xhr';
import { storage } from 'common/storage';
import { addPasswordVisibilityToggleListener } from 'common/password';
import flairPickerLoader from './exports/flairPicker';
+import { confirm } from 'common/dialog';
+import { $as } from 'common';
site.load.then(() => {
$('.emoji-details').each(function (this: HTMLElement) {
@@ -60,8 +62,12 @@ site.load.then(() => {
};
checkDanger();
form.find('input').on('change', checkDanger);
- submit.on('click', function (this: HTMLElement) {
- return !isDanger || confirm(this.title);
+ submit.on('click', function (this: HTMLElement, e: Event) {
+ if (!isDanger) return true;
+ e.preventDefault();
+ confirm(this.title).then(yes => {
+ if (yes) $as(form).submit();
+ });
});
});
@@ -78,7 +84,10 @@ site.load.then(() => {
clean = serialize();
});
window.addEventListener('beforeunload', e => {
- if (clean != serialize() && !confirm('You have unsaved changes. Are you sure you want to leave?'))
+ if (
+ clean != serialize() &&
+ !window.confirm('You have unsaved changes. Are you sure you want to leave?')
+ )
e.preventDefault();
});
});
diff --git a/ui/bits/src/bits.checkout.ts b/ui/bits/src/bits.checkout.ts
index 232a58ce5b2d..328f98e70bb7 100644
--- a/ui/bits/src/bits.checkout.ts
+++ b/ui/bits/src/bits.checkout.ts
@@ -1,6 +1,7 @@
import * as xhr from 'common/xhr';
import { spinnerHtml } from 'common/spinner';
import { contactEmail } from './bits';
+import { alert } from 'common/dialog';
export interface Pricing {
currency: string;
@@ -13,10 +14,7 @@ export interface Pricing {
const $checkout = $('div.plan_checkout');
const getFreq = () => $checkout.find('group.freq input:checked').val();
const getDest = () => $checkout.find('group.dest input:checked').val();
-const showErrorThenReload = (error: string) => {
- alert(error);
- location.assign('/patron');
-};
+const showErrorThenReload = (error: string) => alert(error).then(() => location.assign('/patron'));
export function initModule({ stripePublicKey, pricing }: { stripePublicKey: string; pricing: any }): void {
contactEmail();
diff --git a/ui/bits/src/bits.flairPicker.ts b/ui/bits/src/bits.flairPicker.ts
index ef12a393d12b..f458bd3a3c75 100644
--- a/ui/bits/src/bits.flairPicker.ts
+++ b/ui/bits/src/bits.flairPicker.ts
@@ -2,7 +2,7 @@ import * as emojis from 'emoji-mart';
type Config = {
element: HTMLElement;
- close: () => void;
+ close: (e: PointerEvent) => void;
onEmojiSelect: (i?: { id: string; src: string }) => void;
};
@@ -28,7 +28,7 @@ export async function initModule(cfg: Config): Promise {
};
const picker = new emojis.Picker(opts);
- cfg.element.appendChild(picker as unknown as HTMLElement);
+ cfg.element.prepend(picker as unknown as HTMLElement);
cfg.element.classList.add('emoji-done');
$(cfg.element).find('em-emoji-picker').attr('trap-bypass', '1'); // disable mousetrap within the shadow DOM
}
diff --git a/ui/bits/src/bits.login.ts b/ui/bits/src/bits.login.ts
index c64afff9e0d3..96c564ae4bea 100644
--- a/ui/bits/src/bits.login.ts
+++ b/ui/bits/src/bits.login.ts
@@ -3,6 +3,7 @@ import { debounce } from 'common/timing';
import { addPasswordVisibilityToggleListener } from 'common/password';
import { storedJsonProp } from 'common/storage';
import { spinnerHtml } from 'common/spinner';
+import { alert } from 'common/dialog';
export function initModule(mode: 'login' | 'signup' | 'reset'): void {
mode === 'login' ? loginStart() : mode === 'signup' ? signupStart() : resetStart();
@@ -73,8 +74,9 @@ function loginStart() {
addPasswordVisibilityToggleListener();
load();
} else {
- alert(text || res.statusText + '. Please wait some time before trying again.');
- toggleSubmit($f.find('.submit'), true);
+ alert(
+ (text || res.statusText).slice(0, 300) + '. Please wait some time before trying again.',
+ ).then(() => toggleSubmit($f.find('.submit'), true));
}
} catch (e) {
console.warn(e);
diff --git a/ui/bits/src/bits.plan.ts b/ui/bits/src/bits.plan.ts
index a590cbad8751..fb7fde8febe9 100644
--- a/ui/bits/src/bits.plan.ts
+++ b/ui/bits/src/bits.plan.ts
@@ -1,4 +1,5 @@
import * as xhr from 'common/xhr';
+import { alert } from 'common/dialog';
const showError = (error: string) => alert(error);
diff --git a/ui/bits/src/bits.ts b/ui/bits/src/bits.ts
index fa3378e27d6f..f99835512c45 100644
--- a/ui/bits/src/bits.ts
+++ b/ui/bits/src/bits.ts
@@ -2,6 +2,7 @@ import { text, formToXhr } from 'common/xhr';
import flairPickerLoader from './exports/flairPicker';
import { spinnerHtml } from 'common/spinner';
import { wireCropDialog } from './exports/crop';
+import { confirm } from 'common/dialog';
// avoid node_modules and pay attention to imports here. we don't want to force people
// to download the entire toastui editor library just to do some light form processing.
@@ -191,8 +192,8 @@ function pmAll() {
function practiceNag() {
const el = document.querySelector('.do-reset');
if (!(el instanceof HTMLAnchorElement)) return;
- el.addEventListener('click', () => {
- if (confirm('You will lose your practice progress!')) (el.parentNode as HTMLFormElement).submit();
+ el.addEventListener('click', async () => {
+ if (await confirm('You will lose your practice progress!')) (el.parentNode as HTMLFormElement).submit();
});
}
diff --git a/ui/bits/src/bits.user.ts b/ui/bits/src/bits.user.ts
index d4e0612f91f7..23eb48225cd2 100644
--- a/ui/bits/src/bits.user.ts
+++ b/ui/bits/src/bits.user.ts
@@ -1,6 +1,7 @@
import * as xhr from 'common/xhr';
import { makeLinkPopups } from 'common/linkPopup';
import { pubsub } from 'common/pubsub';
+import { alert } from 'common/dialog';
export function initModule(): void {
makeLinkPopups($('.social_links'));
diff --git a/ui/bits/src/exports/flairPicker.ts b/ui/bits/src/exports/flairPicker.ts
index ddf0e8c7fae7..14ea1048e297 100644
--- a/ui/bits/src/exports/flairPicker.ts
+++ b/ui/bits/src/exports/flairPicker.ts
@@ -1,28 +1,61 @@
-export default function flairPickerLoader(element: HTMLElement): void {
- const parent = $(element).parent();
- const close = () => element.removeAttribute('open');
+import { frag } from 'common';
+
+export default async function flairPickerLoader(element: HTMLElement): Promise {
+ const selectEl = element.querySelector('select')!;
+ const pickerEl = element.querySelector('.flair-picker') as HTMLElement;
+ const removeEl = element.querySelector('.emoji-remove') as HTMLElement;
+ const isOpen = () => !pickerEl.classList.contains('none');
+
+ const toggle = () => {
+ if (isOpen() && (pickerEl.contains(document.activeElement) || document.activeElement === removeEl))
+ selectEl.focus();
+ pickerEl.classList.toggle('none');
+ };
const onEmojiSelect = (i?: { id: string; src: string }) => {
- parent.find('input[name="flair"]').val(i?.id ?? '');
- parent.find('.uflair').remove();
- if (i) parent.find('.flair-container').append('');
- close();
+ element.querySelector('.emoji-popup-button option')?.remove();
+ if (i?.id) selectEl.append(frag(''));
+ element.querySelector('.emoji-popup-button img')!.src = i?.src ?? '';
+ toggle();
};
- parent.find('.emoji-remove').on('click', e => {
+
+ const onClick = async (e: Event) => {
+ if (e instanceof KeyboardEvent && e.key !== 'Enter' && e.key !== ' ') return;
+ e.preventDefault();
+ toggle();
+ selectEl.focus();
+ };
+
+ await Promise.all([
+ site.asset.loadCssPath('bits.flairPicker'),
+ site.asset.loadEsm('bits.flairPicker', {
+ init: {
+ element: element.querySelector('.flair-picker')!,
+ onEmojiSelect,
+ close: (e: PointerEvent) => {
+ if (!isOpen() || selectEl.contains(e.target as Node)) return;
+ toggle();
+ },
+ },
+ }),
+ ]);
+
+ ['mousedown', 'keydown'].forEach(t => selectEl.addEventListener(t, onClick));
+ removeEl.addEventListener('click', e => {
e.preventDefault();
onEmojiSelect();
- $(e.target).remove();
});
- $(element).on('toggle', () =>
- Promise.all([
- site.asset.loadCssPath('bits.flairPicker'),
- site.asset.loadEsm('bits.flairPicker', {
- init: {
- element: element.querySelector('.flair-picker')!,
- close,
- onEmojiSelect,
- },
- }),
- ]),
- );
+
+ element.closest('.dialog-content')?.addEventListener('click', (e: PointerEvent) => {
+ // em's onClickOutside callback does not trigger inside modal dialog, so do it manually
+ if (!isOpen() || [selectEl, pickerEl].some(el => el.contains(e.target as Node))) return;
+ e.preventDefault();
+ toggle();
+ });
+
+ if (!CSS.supports('selector(:has(option))')) {
+ // let old browsers set and remove flairs
+ element.querySelector('img')!.style.display = 'block';
+ element.querySelector('.emoji-remove')!.style.display = 'block';
+ }
}
diff --git a/ui/ceval/src/view/settings.ts b/ui/ceval/src/view/settings.ts
index 48e2458195a3..6db320b25651 100644
--- a/ui/ceval/src/view/settings.ts
+++ b/ui/ceval/src/view/settings.ts
@@ -8,6 +8,7 @@ import { onInsert, bind, dataIcon, looseH as h } from 'common/snabbdom';
import * as Licon from 'common/licon';
import { onClickAway } from 'common';
import { clamp } from 'common/algo';
+import { confirm } from 'common/dialog';
const allSearchTicks: [number, string][] = [
[4000, '4s'],
@@ -216,9 +217,9 @@ function engineSelection(ctrl: ParentCtrl) {
external &&
h('button.delete', {
attrs: { ...dataIcon(Licon.X), title: 'Delete external engine' },
- hook: bind('click', e => {
+ hook: bind('click', async e => {
(e.currentTarget as HTMLElement).blur();
- if (confirm('Remove external engine?'))
+ if (await confirm('Remove external engine?'))
ceval.engines.deleteExternal(external.id).then(ok => ok && ctrl.redraw?.());
}),
}),
diff --git a/ui/chat/src/ctrl.ts b/ui/chat/src/ctrl.ts
index 53594b144830..d0ffb85a13b0 100644
--- a/ui/chat/src/ctrl.ts
+++ b/ui/chat/src/ctrl.ts
@@ -17,6 +17,7 @@ import { moderationCtrl } from './moderation';
import { prop } from 'common';
import { storage, type LichessStorage } from 'common/storage';
import { pubsub, PubsubEvent, PubsubCallback } from 'common/pubsub';
+import { alert } from 'common/dialog';
export default class ChatCtrl {
data: ChatData;
diff --git a/ui/chat/src/discussion.ts b/ui/chat/src/discussion.ts
index 520cdd1741cb..538c04f287dc 100644
--- a/ui/chat/src/discussion.ts
+++ b/ui/chat/src/discussion.ts
@@ -9,6 +9,7 @@ import { presetView } from './preset';
import ChatCtrl from './ctrl';
import { tempStorage } from 'common/storage';
import { pubsub } from 'common/pubsub';
+import { alert } from 'common/dialog';
const whisperRegex = /^\/[wW](?:hisper)?\s/;
diff --git a/ui/chat/src/moderation.ts b/ui/chat/src/moderation.ts
index 26c4c93a25df..4d90bf72fc6d 100644
--- a/ui/chat/src/moderation.ts
+++ b/ui/chat/src/moderation.ts
@@ -7,6 +7,7 @@ import { numberFormat } from 'common/number';
import { userModInfo, flag, timeout } from './xhr';
import ChatCtrl from './ctrl';
import { pubsub } from 'common/pubsub';
+import { confirm } from 'common/dialog';
export function moderationCtrl(opts: ModerationOpts): ModerationCtrl {
let data: ModerationData | undefined;
@@ -60,8 +61,8 @@ export function report(ctrl: ChatCtrl, line: HTMLElement): void {
const text = (line.querySelector('t') as HTMLElement).innerText;
if (userA) reportUserText(ctrl.data.resourceId, userA.href.split('/')[4], text);
}
-function reportUserText(resourceId: string, username: string, text: string) {
- if (confirm(`Report "${text}" to moderators?`)) flag(resourceId, username, text);
+async function reportUserText(resourceId: string, username: string, text: string) {
+ if (await confirm(`Report "${text}" to moderators?`)) flag(resourceId, username, text);
}
export const lineAction = (): VNode => h('action.mod', { attrs: { 'data-icon': licon.Agent } });
@@ -137,8 +138,8 @@ export function moderationView(ctrl?: ModerationCtrl): VNode[] | undefined {
'a.text',
{
attrs: { 'data-icon': licon.Clock },
- hook: bind('click', () => {
- reportUserText(ctrl.opts.resourceId, data.name, data.text);
+ hook: bind('click', async () => {
+ await reportUserText(ctrl.opts.resourceId, data.name, data.text);
ctrl.timeout(ctrl.opts.reasons[0], data.text);
}),
},
diff --git a/ui/cli/src/cli.ts b/ui/cli/src/cli.ts
index cec4d2583c03..6efa138d6fe8 100644
--- a/ui/cli/src/cli.ts
+++ b/ui/cli/src/cli.ts
@@ -1,5 +1,5 @@
import { load as loadDasher } from 'dasher';
-import { domDialog } from 'common/dialog';
+import { alert, domDialog } from 'common/dialog';
import { escapeHtml } from 'common';
import { userComplete } from 'common/userComplete';
diff --git a/ui/common/css/component/_dialog.scss b/ui/common/css/component/_dialog.scss
index b36150d3004c..f7c298df6df5 100644
--- a/ui/common/css/component/_dialog.scss
+++ b/ui/common/css/component/_dialog.scss
@@ -1,3 +1,7 @@
+body:has(dialog.touch-scroll) {
+ overflow: hidden !important;
+}
+
dialog {
@extend %box-radius, %popup-shadow;
position: fixed;
@@ -81,17 +85,27 @@ dialog {
padding: 2em;
color: $c-font;
- &.alert {
+ &.alert,
+ &.debug {
@extend %flex-column;
gap: 2em;
- padding: 2em;
width: unset;
height: unset;
-
- span {
- display: flex;
- justify-content: end;
- gap: 2em;
- }
+ border-radius: 6px;
+ border: 3px solid $c-primary;
+ }
+ &.alert {
+ width: 480px;
+ max-width: 100%;
+ font-size: 16px;
+ }
+ span {
+ display: flex;
+ justify-content: end;
+ gap: 2em;
+ }
+ input {
+ align-self: center;
+ width: 40ch;
}
}
diff --git a/ui/common/css/form/_emoji-picker.scss b/ui/common/css/form/_emoji-picker.scss
index 532b88fbac6b..cfdca7759c57 100644
--- a/ui/common/css/form/_emoji-picker.scss
+++ b/ui/common/css/form/_emoji-picker.scss
@@ -2,9 +2,40 @@
position: relative;
// ensure the emoji picker is above the text and its licon
z-index: 2;
- margin-bottom: 1em;
+ width: 50px;
+
+ &:has(option) img,
+ &:has(option) .emoji-remove {
+ display: block;
+ }
}
.flair-picker {
position: absolute;
+ top: 106%;
+}
+
+.emoji-remove {
+ display: none;
+ position: absolute;
+ right: 1em;
+ bottom: 1em;
+ z-index: 3;
+}
+
+.emoji-popup-button {
+ position: relative;
+
+ select.form-control {
+ position: absolute;
+ width: 50px;
+ }
+ img {
+ display: none;
+ position: absolute;
+ pointer-events: none;
+ inset: 9px;
+ width: 22px;
+ height: 22px;
+ }
}
diff --git a/ui/common/src/algo.ts b/ui/common/src/algo.ts
index 13f08345931f..bb860c26a14f 100644
--- a/ui/common/src/algo.ts
+++ b/ui/common/src/algo.ts
@@ -1,6 +1,6 @@
export const randomToken = (): string => {
try {
- const data = window.crypto.getRandomValues(new Uint8Array(9));
+ const data = globalThis.crypto.getRandomValues(new Uint8Array(9));
return btoa(String.fromCharCode(...data)).replace(/[/+]/g, '_');
} catch (_) {
return Math.random().toString(36).slice(2, 12);
@@ -11,6 +11,10 @@ export function clamp(value: number, bounds: { min?: number; max?: number }): nu
return Math.max(bounds.min ?? -Infinity, Math.min(value, bounds.max ?? Infinity));
}
+export function quantize(n?: number, factor = 2000): number {
+ return Math.floor((n ?? 0) / factor) * factor;
+}
+
export function shuffle(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
@@ -59,33 +63,3 @@ export function findMapped(arr: T[], callback: (el: T) => U | undefined):
}
return undefined;
}
-
-export type SparseSet = Set | T;
-export type SparseMap = Map>;
-
-export function spread(v: undefined | SparseSet): T[] {
- return v === undefined ? [] : v instanceof Set ? [...v] : [v];
-}
-
-export function spreadMap(m: SparseMap): [string, T[]][] {
- return [...m].map(([k, v]) => [k, spread(v)]);
-}
-
-export function getSpread(m: SparseMap, key: string): T[] {
- return spread(m.get(key));
-}
-
-export function remove(m: SparseMap, key: string, val: T): void {
- const v = m.get(key);
- if (v === val) m.delete(key);
- else if (v instanceof Set) v.delete(val);
-}
-
-export function pushMap(m: SparseMap, key: string, val: T): void {
- const v = m.get(key);
- if (!v) m.set(key, val);
- else {
- if (v instanceof Set) v.add(val);
- else if (v !== val) m.set(key, new Set([v as T, val]));
- }
-}
diff --git a/ui/common/src/dialog.ts b/ui/common/src/dialog.ts
index 02990926ef5e..023fe1933a28 100644
--- a/ui/common/src/dialog.ts
+++ b/ui/common/src/dialog.ts
@@ -69,17 +69,27 @@ export async function alert(msg: string): Promise {
await domDialog({
htmlText: escapeHtml(msg),
class: 'alert',
- show: 'modal',
+ show: true,
});
}
+export async function alerts(msgs: string[]): Promise {
+ for (const msg of msgs) await alert(msg);
+}
+
// non-blocking window.confirm-alike
-export async function confirm(msg: string): Promise {
+export async function confirm(
+ msg: string,
+ yes: string = i18n.site.yes,
+ no: string = i18n.site.no,
+): Promise {
return (
(
await domDialog({
- htmlText: `${escapeHtml(msg)}
- `,
+ htmlText:
+ `${escapeHtml(msg)}
` +
+ `` +
+ ``,
class: 'alert',
noCloseButton: true,
noClickAway: true,
@@ -93,6 +103,42 @@ export async function confirm(msg: string): Promise {
);
}
+// non-blocking window.prompt-alike
+export async function prompt(
+ msg: string,
+ def: string = '',
+ ok: string = 'OK',
+ cancel: string = i18n.site.cancel,
+): Promise {
+ const res = await domDialog({
+ htmlText:
+ `${escapeHtml(msg)}
` +
+ `` +
+ `` +
+ ``,
+ class: 'alert',
+ noCloseButton: true,
+ noClickAway: true,
+ show: 'modal',
+ focus: 'input',
+ actions: [
+ { selector: '.ok', result: 'ok' },
+ { selector: '.cancel', result: 'cancel' },
+ {
+ selector: 'input',
+ event: 'keydown',
+ listener: (e: KeyboardEvent, dlg) => {
+ if (e.key !== 'Enter' && e.key !== 'Escape') return;
+ e.preventDefault();
+ if (e.key === 'Enter') dlg.close('ok');
+ else if (e.key === 'Escape') dlg.close('cancel');
+ },
+ },
+ ],
+ });
+ return res.returnValue === 'ok' ? (res.view.querySelector('input') as HTMLInputElement).value : null;
+}
+
// when opts contains 'show', this promise resolves as show/showModal (on dialog close) so check returnValue
// otherwise, this promise resolves once assets are loaded and things are fully constructed but not shown
export async function domDialog(o: DomDialogOpts): Promise