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 { @@ -180,7 +226,6 @@ export function snabDialog(o: SnabDialogOpts): VNode { } class DialogWrapper implements Dialog { - private restore?: { focus?: HTMLElement; overflow: string }; private resolve?: (dialog: Dialog) => void; private actionEvents = eventJanitor(); private dialogEvents = eventJanitor(); @@ -293,9 +338,6 @@ class DialogWrapper implements Dialog { private onRemove = () => { this.observer.disconnect(); if (!this.dialog.returnValue) this.dialog.returnValue = 'cancel'; - this.restore?.focus?.focus(); // one modal at a time please - if (this.restore?.overflow !== undefined) document.body.style.overflow = this.restore.overflow; - this.restore = undefined; this.resolve?.(this); this.o.onClose?.(this); this.dialog.remove(); diff --git a/ui/common/src/permalog.ts b/ui/common/src/permalog.ts index 491b9c408ae8..9ddca4515ddf 100644 --- a/ui/common/src/permalog.ts +++ b/ui/common/src/permalog.ts @@ -1,5 +1,6 @@ import { objectStorage, ObjectStorage, DbInfo } from './objectStorage'; -import { alert } from './dialog'; +import { domDialog } from './dialog'; +import { escapeHtml } from './common'; export const log: LichessLog = makeLog(); @@ -88,11 +89,21 @@ function makeLog(): LichessLog { window.addEventListener('error', async e => { const loc = e.filename ? ` - (${e.filename}:${e.lineno}:${e.colno})` : ''; log(`${terseHref()} - ${e.message}${loc}\n${e.error?.stack ?? ''}`.trim()); - if (site.debug) alert(`${e.message}${loc}\n${e.error?.stack ?? ''}`); + if (site.debug) + domDialog({ + htmlText: escapeHtml(`${e.message}${loc}\n${e.error?.stack ?? ''}`), + class: 'debug', + show: true, + }); }); window.addEventListener('unhandledrejection', async e => { log(`${terseHref()} - ${e.reason}`); - if (site.debug) alert(`${e.reason}`); + if (site.debug) + domDialog({ + htmlText: escapeHtml(e.reason), + class: 'debug', + show: true, + }); }); return log; diff --git a/ui/learn/src/mapSideView.ts b/ui/learn/src/mapSideView.ts index 03c751dfb062..9e180e048e11 100644 --- a/ui/learn/src/mapSideView.ts +++ b/ui/learn/src/mapSideView.ts @@ -5,6 +5,7 @@ import { h } from 'snabbdom'; import { bind } from 'common/snabbdom'; import { BASE_LEARN_PATH, hashHref } from './hashRouting'; import { LearnCtrl } from './ctrl'; +import { confirm } from 'common/dialog'; export function mapSideView(ctrl: LearnCtrl) { if (ctrl.inStage()) return renderInStage(ctrl.sideCtrl); @@ -69,7 +70,9 @@ function renderHome(ctrl: SideCtrl) { ? h( 'a.confirm', { - hook: bind('click', () => confirm(i18n.learn.youWillLoseAllYourProgress) && ctrl.reset()), + hook: bind('click', async () => { + if (await confirm(i18n.learn.youWillLoseAllYourProgress)) ctrl.reset(); + }), }, i18n.learn.resetMyProgress, ) diff --git a/ui/lobby/src/ctrl.ts b/ui/lobby/src/ctrl.ts index 1dbe52eac93c..37fd7162f580 100644 --- a/ui/lobby/src/ctrl.ts +++ b/ui/lobby/src/ctrl.ts @@ -209,16 +209,18 @@ export default class LobbyController { if (this.tab !== 'real_time') this.redraw(); }; - clickHook = (id: string) => { + clickHook = async (id: string) => { const hook = hookRepo.find(this, id); if (!hook || hook.disabled || this.stepping || this.redirecting) return; - if (hook.action === 'cancel' || variantConfirm(hook.variant)) this.socket.send(hook.action, hook.id); + if (hook.action === 'cancel' || (await variantConfirm(hook.variant))) + this.socket.send(hook.action, hook.id); }; - clickSeek = (id: string) => { + clickSeek = async (id: string) => { const seek = seekRepo.find(this, id); if (!seek || this.redirecting) return; - if (seek.action === 'cancelSeek' || variantConfirm(seek.variant)) this.socket.send(seek.action, seek.id); + if (seek.action === 'cancelSeek' || (await variantConfirm(seek.variant?.key))) + this.socket.send(seek.action, seek.id); }; setSeeks = (seeks: Seek[]) => { diff --git a/ui/lobby/src/interfaces.ts b/ui/lobby/src/interfaces.ts index f174a4a71dd2..7ff959e2bfd5 100644 --- a/ui/lobby/src/interfaces.ts +++ b/ui/lobby/src/interfaces.ts @@ -46,7 +46,7 @@ export interface Seek { key: Exclude; }; provisional?: boolean; - variant?: string; + variant?: { key: VariantKey }; action: 'joinSeek' | 'cancelSeek'; } diff --git a/ui/lobby/src/setupCtrl.ts b/ui/lobby/src/setupCtrl.ts index 350fc115a3e0..3540cdd5e1e1 100644 --- a/ui/lobby/src/setupCtrl.ts +++ b/ui/lobby/src/setupCtrl.ts @@ -23,6 +23,7 @@ import { timeVToTime, variants, } from './options'; +import { alert } from 'common/dialog'; const getPerf = (variant: VariantKey, timeMode: TimeMode, time: RealValue, increment: RealValue): Perf => variant != 'standard' && variant != 'fromPosition' @@ -319,7 +320,7 @@ export default class SetupController { if (!ok) { const errs: { [key: string]: string } = await response.json(); - alert( + await alert( errs ? Object.keys(errs) .map(k => `${k}: ${errs[k]}`) diff --git a/ui/lobby/src/variant.ts b/ui/lobby/src/variant.ts index 9f417c07cd29..8178375c0925 100644 --- a/ui/lobby/src/variant.ts +++ b/ui/lobby/src/variant.ts @@ -1,6 +1,7 @@ import { storage } from 'common/storage'; +import { confirm } from 'common/dialog'; -const variantConfirms = { +const variantConfirms: Record = { chess960: "This is a Chess960 game!\n\nThe starting position of the pieces on the players' home ranks is randomized.", kingOfTheHill: @@ -20,15 +21,9 @@ const variantConfirms = { const storageKey = (key: string) => 'lobby.variant.' + key; -export default function (variant?: string) { - return ( - !variant || - Object.keys(variantConfirms).every(function (key: keyof typeof variantConfirms) { - if (variant === key && !storage.get(storageKey(key))) { - const c = confirm(variantConfirms[key]); - if (c) storage.set(storageKey(key), '1'); - return c; - } else return true; - }) - ); +export default async function (variant: string | undefined) { + if (!variant || !variantConfirms[variant] || storage.get(storageKey(variant))) return true; + const confirmed = await confirm(variantConfirms[variant]); + if (confirmed) storage.set(storageKey(variant), '1'); + return confirmed; } diff --git a/ui/lobby/src/view/correspondence.ts b/ui/lobby/src/view/correspondence.ts index f35fb7b76285..6c8d70246d81 100644 --- a/ui/lobby/src/view/correspondence.ts +++ b/ui/lobby/src/view/correspondence.ts @@ -4,6 +4,7 @@ import { tds, perfNames } from './util'; import LobbyController from '../ctrl'; import { Seek } from '../interfaces'; import perfIcons from 'common/perfIcons'; +import { confirm } from 'common/dialog'; function renderSeek(ctrl: LobbyController, seek: Seek): VNode { const klass = seek.action === 'joinSeek' ? 'join' : 'cancel'; @@ -66,13 +67,14 @@ export default function (ctrl: LobbyController): MaybeVNodes { h( 'tbody', { - hook: bind('click', e => { + hook: bind('click', async e => { let el = e.target as HTMLElement; do { el = el.parentNode as HTMLElement; if (el.nodeName === 'TR') { if (!ctrl.me) { - if (confirm(i18n.site.youNeedAnAccountToDoThat)) location.href = '/signup'; + if (await confirm(i18n.site.youNeedAnAccountToDoThat, i18n.site.signUp, i18n.site.cancel)) + location.href = '/signup'; return; } return ctrl.clickSeek(el.dataset['id']!); diff --git a/ui/lobby/src/view/realTime/list.ts b/ui/lobby/src/view/realTime/list.ts index 78157b6a81fa..644a7f9ca9c3 100644 --- a/ui/lobby/src/view/realTime/list.ts +++ b/ui/lobby/src/view/realTime/list.ts @@ -99,7 +99,7 @@ export const render = (ctrl: LobbyController, allHooks: Hook[]) => { class: { stepping: ctrl.stepping }, hook: bind( 'click', - e => { + async e => { let el = e.target as HTMLElement; do { el = el.parentNode as HTMLElement; diff --git a/ui/mod/css/_communication.scss b/ui/mod/css/_communication.scss index 2b1850aac039..1ab15bfaec19 100644 --- a/ui/mod/css/_communication.scss +++ b/ui/mod/css/_communication.scss @@ -98,7 +98,7 @@ .threads .post.author { opacity: 1; cursor: pointer; - border-bottom: 1px dashed $c-accent; + border-bottom: 1px dashed $m-accent--fade-50; min-width: 6ch; &:hover { background: $m-accent--fade-80; diff --git a/ui/mod/css/_mod-timeline.scss b/ui/mod/css/_mod-timeline.scss index f45ddffb98ac..17ed896fc208 100644 --- a/ui/mod/css/_mod-timeline.scss +++ b/ui/mod/css/_mod-timeline.scss @@ -1,6 +1,6 @@ .mod-timeline { @extend %flex-column, %box-neat; - gap: 1em; + gap: 0.6em; background: $c-bg-zebra; padding: 1em 1em 1em 2em; max-height: 70vh; @@ -11,16 +11,17 @@ @extend %flex-center-nowrap; gap: 1em; h3 { + white-space: nowrap; flex: 0 0 11ch; - font-size: 1.2em; + font-size: 1.1em; text-transform: capitalize; } border-bottom: $border; - padding-bottom: 1em; + padding-bottom: 0.6em; } .mod-timeline__period__events { @extend %flex-column; - gap: 0.3em; + gap: 0.2em; flex: 1 1 auto; } .mod-timeline__event { @@ -50,10 +51,11 @@ flex: 0 0 auto; } } -.mod-timeline__event--modlog:has(i.moderator) { +.mod-timeline__event:has(i.moderator) { .user-link, - .user-link i { + .user-link i.line { color: $c-brag; + opacity: 0.7; } } .mod-timeline__event__action { diff --git a/ui/mod/css/_user.scss b/ui/mod/css/_user.scss index 14a746d40b7c..6c3de9d42208 100644 --- a/ui/mod/css/_user.scss +++ b/ui/mod/css/_user.scss @@ -343,10 +343,7 @@ flex: 1 0 50ch; max-height: 50vh; overflow-y: auto; - - &:first-child { - margin-inline-end: 1em; - } + padding: 0 1em 1em 2em; .score { display: inline-block; diff --git a/ui/mod/src/mod.games.ts b/ui/mod/src/mod.games.ts index 497fa9155e41..8017e54cec26 100644 --- a/ui/mod/src/mod.games.ts +++ b/ui/mod/src/mod.games.ts @@ -3,6 +3,7 @@ import tablesort from 'tablesort'; import { debounce } from 'common/timing'; import { formToXhr } from 'common/xhr'; import { checkBoxAll, expandCheckboxZone, shiftClickCheckboxRange } from './checkBoxes'; +import { confirm } from 'common/dialog'; site.load.then(() => { setupTable(); @@ -35,23 +36,20 @@ const setupActionForm = () => { const form = document.querySelector('.mod-games__analysis-form') as HTMLFormElement; const debouncedSubmit = debounce( () => - formToXhr(form).then(() => { - const reload = confirm('Analysis completed. Reload the page?'); - if (reload) site.reload(); + formToXhr(form).then(async () => { + if (await confirm('Analysis completed. Reload the page?')) site.reload(); }), 1000, ); - $(form).on('click', 'button', (e: Event) => { + $(form).on('click', 'button', async (e: Event) => { const button = e.target as HTMLButtonElement; const action = button.getAttribute('value'); const nbSelected = form.querySelectorAll('input:checked').length; - if (nbSelected < 1) return false; - if (action == 'analyse') { - if (nbSelected >= 20 && !confirm(`Analyse ${nbSelected} games?`)) return; - $(form).find('button[value="analyse"]').text('Sent').prop('disabled', true); - debouncedSubmit(); - return false; - } - return; + if (action !== 'analyse') return; + e.preventDefault(); + if (nbSelected < 1) return; + if (nbSelected >= 20 && !(await confirm(`Analyse ${nbSelected} games?`))) return; + $(form).find('button[value="analyse"]').text('Sent').prop('disabled', true); + debouncedSubmit(); }); }; diff --git a/ui/mod/src/mod.inquiry.ts b/ui/mod/src/mod.inquiry.ts index fc7bb4b1eba4..36864d3df559 100644 --- a/ui/mod/src/mod.inquiry.ts +++ b/ui/mod/src/mod.inquiry.ts @@ -2,6 +2,7 @@ import * as xhr from 'common/xhr'; import { expandMentions } from 'common/richText'; import { storage } from 'common/storage'; +import { alert } from 'common/dialog'; site.load.then(() => { const noteStore = storage.make('inquiry-note'); diff --git a/ui/mod/src/mod.search.ts b/ui/mod/src/mod.search.ts index c887da3280df..626401e57ee3 100644 --- a/ui/mod/src/mod.search.ts +++ b/ui/mod/src/mod.search.ts @@ -2,12 +2,13 @@ import * as xhr from 'common/xhr'; import extendTablesortNumber from 'common/tablesortNumber'; import tablesort from 'tablesort'; import { checkBoxAll, expandCheckboxZone, selector, shiftClickCheckboxRange } from './checkBoxes'; +import { confirm } from 'common/dialog'; site.load.then(() => { $('.slist, slist-pad') .find('.mark-alt') - .on('click', function (this: HTMLAnchorElement) { - if (confirm('Close alt account?')) { + .on('click', async function (this: HTMLAnchorElement) { + if (await confirm('Close alt account?')) { xhr.text(this.getAttribute('href')!, { method: 'post' }); $(this).remove(); } @@ -31,7 +32,7 @@ site.load.then(() => { .find('td:last-child input:checked') .map((_, input) => $(input).parents('tr').find('td:first-child').data('sort')), ); - if (usernames.length > 0 && confirm(`Close ${usernames.length} alt accounts?`)) { + if (usernames.length > 0 && (await confirm(`Close ${usernames.length} alt accounts?`))) { console.log(usernames); await xhr.text('/mod/alt-many', { method: 'post', body: usernames.join(' ') }); location.reload(); diff --git a/ui/mod/src/mod.user.ts b/ui/mod/src/mod.user.ts index 0fba7755cad8..b34df805f0e3 100644 --- a/ui/mod/src/mod.user.ts +++ b/ui/mod/src/mod.user.ts @@ -6,6 +6,7 @@ import tablesort from 'tablesort'; import { expandCheckboxZone, shiftClickCheckboxRange, selector } from './checkBoxes'; import { spinnerHtml } from 'common/spinner'; import { pubsub } from 'common/pubsub'; +import { confirm } from 'common/dialog'; site.load.then(() => { const $toggle = $('.mod-zone-toggle'), @@ -74,8 +75,9 @@ site.load.then(() => { const confirmButton = (el: HTMLElement) => $(el) .find('input.confirm, button.confirm') - .on('click', function (this: HTMLElement) { - return confirm(this.title || 'Confirm this action?'); + .on('click', async function (this: HTMLElement, e: Event) { + e.preventDefault(); + if (await confirm(this.title || 'Confirm this action?')) this.closest('form')?.submit(); }); $('.mz-section--menu > a:not(.available)').each(function (this: HTMLAnchorElement) { @@ -136,7 +138,7 @@ site.load.then(() => { .find('td:last-child input:checked') .map((_, input) => $(input).parents('tr').find('td:first-child').data('sort')), ); - if (usernames.length > 0 && confirm(`Close ${usernames.length} alt accounts?`)) { + if (usernames.length > 0 && (await confirm(`Close ${usernames.length} alt accounts?`))) { await xhr.text('/mod/alt-many', { method: 'post', body: usernames.join(' ') }); reloadZone(); } diff --git a/ui/msg/src/view/interact.ts b/ui/msg/src/view/interact.ts index 7a448b061a04..9104277f5c67 100644 --- a/ui/msg/src/view/interact.ts +++ b/ui/msg/src/view/interact.ts @@ -4,6 +4,7 @@ import { bindSubmit } from 'common/snabbdom'; import { User } from '../interfaces'; import MsgCtrl from '../ctrl'; import { throttle } from 'common/timing'; +import { alert } from 'common/dialog'; export default function renderInteract(ctrl: MsgCtrl, user: User): VNode { const connected = ctrl.connected(); @@ -49,7 +50,10 @@ function setupTextarea(area: HTMLTextAreaElement, contact: string, ctrl: MsgCtrl if (prev > now - 1000 || !ctrl.connected()) return; prev = now; const txt = area.value.trim(); - if (txt.length > 8000) return alert('The message is too long.'); + if (txt.length > 8000) { + alert('The message is too long.'); + return; + } if (txt) ctrl.post(txt); area.value = ''; area.dispatchEvent(new Event('input')); // resize the textarea diff --git a/ui/msg/src/view/msgs.ts b/ui/msg/src/view/msgs.ts index 15fe6d7c9660..e15c6f3eaa27 100644 --- a/ui/msg/src/view/msgs.ts +++ b/ui/msg/src/view/msgs.ts @@ -6,6 +6,7 @@ import * as enhance from './enhance'; import { makeLinkPopups } from 'common/linkPopup'; import { scroller } from './scroller'; import MsgCtrl from '../ctrl'; +import { alert, confirm } from 'common/dialog'; export default function renderMsgs(ctrl: MsgCtrl, convo: Convo): VNode { return h('div.msg-app__convo__msgs', { hook: { insert: setupMsgs(true), postpatch: setupMsgs(false) } }, [ @@ -120,8 +121,8 @@ const setupMsgs = (insert: boolean) => (vnode: VNode) => { scroller.toMarker() || scroller.auto(); }; -const teamUnsub = (form: HTMLFormElement) => { - if (confirm('Unsubscribe?')) +const teamUnsub = async (form: HTMLFormElement) => { + if (await confirm('Unsubscribe?')) xhr .json(form.action, { method: 'post', diff --git a/ui/palantir/src/palantir.ts b/ui/palantir/src/palantir.ts index 8ae7e246d2c7..3dcb652e6bad 100644 --- a/ui/palantir/src/palantir.ts +++ b/ui/palantir/src/palantir.ts @@ -2,6 +2,7 @@ import type * as snabbdom from 'snabbdom'; import * as licon from 'common/licon'; import Peer from 'peerjs'; import { pubsub } from 'common/pubsub'; +import { alert } from 'common/dialog'; type State = | 'off' diff --git a/ui/puzzle/src/ctrl.ts b/ui/puzzle/src/ctrl.ts index ef05b4b5470d..b97aaba0731b 100755 --- a/ui/puzzle/src/ctrl.ts +++ b/ui/puzzle/src/ctrl.ts @@ -39,6 +39,7 @@ import { uciToMove } from 'chessground/util'; import { Redraw } from 'common/snabbdom'; import { ParentCtrl } from 'ceval/src/types'; import { pubsub } from 'common/pubsub'; +import { alert } from 'common/dialog'; export default class PuzzleCtrl implements ParentCtrl { data: PuzzleData; @@ -454,11 +455,9 @@ export default class PuzzleCtrl implements ParentCtrl { if (this.streak && win) this.streak.onComplete(true, res.next); } this.redraw(); - if (!next) { - if (!this.data.replay) { - alert('No more puzzles available! Try another theme.'); - site.redirect('/training/themes'); - } + if (!next && !this.data.replay) { + await alert('No more puzzles available! Try another theme.'); + site.redirect('/training/themes'); } }; diff --git a/ui/simul/src/view/created.ts b/ui/simul/src/view/created.ts index 6d7bd335cfff..02098e22abbd 100644 --- a/ui/simul/src/view/created.ts +++ b/ui/simul/src/view/created.ts @@ -1,6 +1,6 @@ import { VNode } from 'snabbdom'; import * as licon from 'common/licon'; -import { domDialog } from 'common/dialog'; +import { confirm, domDialog } from 'common/dialog'; import { bind, looseH as h } from 'common/snabbdom'; import SimulCtrl from '../ctrl'; import { Applicant } from '../interfaces'; @@ -190,8 +190,8 @@ const startOrCancel = (ctrl: SimulCtrl, accepted: Applicant[]) => 'a.button.button-red.text', { attrs: { 'data-icon': licon.X }, - hook: bind('click', () => { - if (confirm('Delete this simul?')) xhr.abort(ctrl.data.id); + hook: bind('click', async () => { + if (await confirm('Delete this simul?')) xhr.abort(ctrl.data.id); }), }, i18n.site.cancel, diff --git a/ui/site/src/boot.ts b/ui/site/src/boot.ts index a3690e24cece..7a98281f985d 100644 --- a/ui/site/src/boot.ts +++ b/ui/site/src/boot.ts @@ -16,6 +16,7 @@ import { userComplete } from 'common/userComplete'; import { updateTimeAgo, renderTimeAgo } from './renderTimeAgo'; import { pubsub } from 'common/pubsub'; import { toggleBoxInit } from 'common/controls'; +import { confirm } from 'common/dialog'; export function boot() { $('#user_tag').removeAttr('href'); @@ -90,8 +91,10 @@ export function boot() { else $(this).one('focus', start); }); - $('input.confirm, button.confirm').on('click', function (this: HTMLElement) { - return confirm(this.title || 'Confirm this action?'); + $('input.confirm, button.confirm').on('click', async function (this: HTMLElement, e: Event) { + if (!e.isTrusted) return; + e.preventDefault(); + if (await confirm(this.title || 'Confirm this action?')) (e.target as HTMLElement)?.click(); }); $('#main-wrap').on('click', 'a.bookmark', function (this: HTMLAnchorElement) { diff --git a/ui/tournament/src/ctrl.ts b/ui/tournament/src/ctrl.ts index 882915bb3bb0..4c0b4724b4ad 100644 --- a/ui/tournament/src/ctrl.ts +++ b/ui/tournament/src/ctrl.ts @@ -5,6 +5,7 @@ import * as sound from './sound'; import { TournamentData, TournamentOpts, Pages, PlayerInfo, TeamInfo, Standing, Player } from './interfaces'; import { storage } from 'common/storage'; import { pubsub } from 'common/pubsub'; +import { alerts } from 'common/dialog'; interface CtrlTeamInfo { requested?: string; @@ -140,12 +141,10 @@ export default class TournamentController { this.focusOnMe = false; }; - join = (team?: string) => { + join = async (team?: string) => { this.joinWithTeamSelector = false; if (!this.data.verdicts.accepted) - return this.data.verdicts.list.forEach(v => { - if (v.verdict !== 'ok') alert(v.verdict); - }); + return await alerts(this.data.verdicts.list.map(v => v.verdict).filter(v => v != 'ok')); if (this.data.teamBattle && !team && !this.data.me) { this.joinWithTeamSelector = true; } else { @@ -160,6 +159,7 @@ export default class TournamentController { this.joinSpinner = true; this.focusOnMe = true; } + return; }; scrollToMe = () => this.setPage(myPage(this)); diff --git a/ui/tournament/src/xhr.ts b/ui/tournament/src/xhr.ts index f16799f3e62b..90d3b198ee2c 100644 --- a/ui/tournament/src/xhr.ts +++ b/ui/tournament/src/xhr.ts @@ -1,6 +1,7 @@ import { finallyDelay, throttlePromiseDelay } from 'common/timing'; import * as xhr from 'common/xhr'; import TournamentController from './ctrl'; +import { alert } from 'common/dialog'; // when the tournament no longer exists // randomly delay reloads in case of massive tournament to avoid ddos diff --git a/ui/voice/src/move/voice.move.ts b/ui/voice/src/move/voice.move.ts index 2e0f9de88220..66740e5c4e48 100644 --- a/ui/voice/src/move/voice.move.ts +++ b/ui/voice/src/move/voice.move.ts @@ -8,9 +8,19 @@ import { MoveRootCtrl, MoveUpdate } from 'chess/moveRootCtrl'; import { VoiceMove, VoiceCtrl, Entry, Match } from '../voice'; import { coloredArrows, numberedArrows, brushes } from './arrows'; import { settingNodes } from './view'; -import { spread, type SparseMap, spreadMap, getSpread, remove, pushMap } from 'common/algo'; -import { type Transform, movesTo, findTransforms, as } from '../util'; import { MsgType } from '../interfaces'; +import { + type Transform, + type SparseMap, + spread, + spreadMap, + getSpread, + remove, + pushMap, + movesTo, + findTransforms, + as, +} from '../util'; export function initModule({ root, @@ -40,8 +50,8 @@ export function initModule({ let choices: Map | undefined; // map choice arrows (yes, blue, red, 1, 2, etc) to moves let choiceTimeout: number | undefined; // timeout for ambiguity choices const clarityPref = prop.storedIntProp('voice.clarity', 0); - const colorsPref = prop.storedBooleanPropWithEffect('voice.useColors', true, _ => initTimerRec()); - const timerPref = prop.storedIntPropWithEffect('voice.timer', 3, _ => initTimerRec()); + const colorsPref = prop.storedBooleanPropWithEffect('voice.useColors', true, () => initTimerRec()); + const timerPref = prop.storedIntPropWithEffect('voice.timer', 3, () => initTimerRec()); const listenHandlers = [handleConfirm, handleCommand, handleAmbiguity, handleMove]; diff --git a/ui/voice/src/util.ts b/ui/voice/src/util.ts index 0cfe3b708fab..9bc680212cb4 100644 --- a/ui/voice/src/util.ts +++ b/ui/voice/src/util.ts @@ -83,3 +83,33 @@ export function as(v: T, f: () => void): () => T { return v; }; } + +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])); + } +}