diff --git a/app/controllers/Clas.scala b/app/controllers/Clas.scala index b27410747d61d..ce44029ffc7e1 100644 --- a/app/controllers/Clas.scala +++ b/app/controllers/Clas.scala @@ -472,6 +472,24 @@ final class Clas(env: Env, authC: Auth) extends LilaController(env): else redirectTo(clas) } + def studentMove(id: ClasId, username: UserStr) = Secure(_.Teacher) { ctx ?=> me ?=> + WithClassAndStudents(id): (clas, students) => + WithStudent(clas, username): s => + for + classes <- env.clas.api.clas.of(me) + others = classes.filter(_.id != clas.id) + res <- Ok.page(views.clas.student.move(clas, students, s, others)) + yield res + } + + def studentMovePost(id: ClasId, username: UserStr, to: ClasId) = SecureBody(_.Teacher) { ctx ?=> me ?=> + WithClassAndStudents(id): (clas, students) => + WithStudent(clas, username): s => + WithClass(to): toClas => + for _ <- env.clas.api.student.move(s, toClas) + yield Redirect(routes.Clas.show(clas.id)).flashSuccess + } + def becomeTeacher = AuthBody { ctx ?=> me ?=> couldBeTeacher.elseNotFound: val perm = lila.core.perm.Permission.Teacher.dbKey diff --git a/app/controllers/User.scala b/app/controllers/User.scala index c189717e8bc82..0b73d2fbd6440 100644 --- a/app/controllers/User.scala +++ b/app/controllers/User.scala @@ -44,10 +44,9 @@ final class User( env.game.cached .lastPlayedPlayingId(username.id) .orElse(env.game.gameRepo.quickLastPlayedId(username.id)) - .flatMap { + .flatMap: case None => NotFound("No ongoing game") case Some(gameId) => gameC.exportGame(gameId) - } private def apiGames(u: UserModel, filter: String, page: Int)(using BodyContext[?]) = userGames(u, filter, page).flatMap(env.game.userGameApi.jsPaginator).map { res => @@ -177,11 +176,12 @@ final class User( ctx.userId.soFu(env.game.crosstableApi(user.id, _)), ctx.isAuth.so(env.pref.api.followable(user.id)) ).flatMapN: (blocked, crosstable, followable) => - val ping = env.socket.isOnline.exec(user.id).so(env.socket.getLagRating(user.id)) negotiate( - html = (ctx.isnt(user)).so(currentlyPlaying(user.user)).flatMap { pov => - Ok.snip(views.user.mini(user, pov, blocked, followable, relation, ping, crosstable)) - .map(_.withHeaders(CACHE_CONTROL -> "max-age=5")) + html = ctx.isnt(user).so(currentlyPlaying(user.user)).flatMap { pov => + val ping = env.socket.isOnline.exec(user.id).so(env.socket.getLagRating(user.id)) + Ok.snip( + views.user.mini(user, pov, blocked, followable, relation, ping, crosstable) + ).map(_.withHeaders(CACHE_CONTROL -> "max-age=5")) }, json = import lila.game.JsonView.given diff --git a/app/views/base/page.scala b/app/views/base/page.scala index 916fd1142c5ac..eedd9468c261c 100644 --- a/app/views/base/page.scala +++ b/app/views/base/page.scala @@ -101,7 +101,7 @@ object page: "kid" -> ctx.kid.yes, "mobile" -> lila.common.HTTPRequest.isMobileBrowser(ctx.req), "playing fixed-scroll" -> p.playing, - "no-rating" -> !pref.showRatings, + "no-rating" -> (!pref.showRatings || (p.playing && pref.hideRatingsInGame)), "no-flair" -> !pref.flairs, "zen" -> (pref.isZen || (p.playing && pref.isZenAuto)), "zenable" -> p.zenable, diff --git a/app/views/clas.scala b/app/views/clas.scala index 8811ff105f2df..3a8518dc90c0f 100644 --- a/app/views/clas.scala +++ b/app/views/clas.scala @@ -18,7 +18,7 @@ object student: lazy val formUi = lila.clas.ui.StudentFormUi(helpers, views.clas.ui, ui) export ui.{ invite } - export formUi.{ newStudent as form, many as manyForm, edit, release, close } + export formUi.{ newStudent as form, many as manyForm, edit, release, close, move } def show( clas: Clas, diff --git a/app/views/game/side.scala b/app/views/game/side.scala index 6927ebf3e28cf..d68b06d89fa31 100644 --- a/app/views/game/side.scala +++ b/app/views/game/side.scala @@ -78,7 +78,12 @@ object side: game.players.mapList: p => frag( div(cls := s"player color-icon is ${p.color.name} text")( - playerLink(p, withOnline = false, withDiff = true, withBerserk = true) + playerLink( + p, + withOnline = false, + withDiff = true, + withBerserk = true + ) ), tour.flatMap(_.teamVs).map(_.teams(p.color)).map { teamLink(_, withIcon = false)(cls := "team") diff --git a/conf/clas.routes b/conf/clas.routes index 7d119b15396be..893c5a1bc7c8b 100644 --- a/conf/clas.routes +++ b/conf/clas.routes @@ -32,3 +32,5 @@ POST /class/$id<\w{8}>/student/:username/release controllers.clas.Clas.studentR GET /class/$id<\w{8}>/student/:username/close controllers.clas.Clas.studentClose(id: ClasId, username: UserStr) POST /class/$id<\w{8}>/student/:username/close controllers.clas.Clas.studentClosePost(id: ClasId, username: UserStr) POST /class/$id<\w{8}>/invitation/revoke controllers.clas.Clas.invitationRevoke(id: ClasInviteId) +GET /class/$id<\w{8}>/student/:username/move controllers.clas.Clas.studentMove(id: ClasId, username: UserStr) +POST /class/$id<\w{8}>/student/:username/move/$to<\w{8}> controllers.clas.Clas.studentMovePost(id: ClasId, username: UserStr, to: ClasId) diff --git a/modules/api/src/main/Context.scala b/modules/api/src/main/Context.scala index e60568e3cbcb8..4088a6aa9b047 100644 --- a/modules/api/src/main/Context.scala +++ b/modules/api/src/main/Context.scala @@ -20,7 +20,6 @@ final class LoginContext( val oauth: Option[TokenScopes] ): export me.{ isDefined as isAuth, isEmpty as isAnon } - def myId: Option[MyId] = me.map(_.myId) def user: Option[User] = Me.raw(me) def userId: Option[UserId] = user.map(_.id) def username: Option[UserName] = user.map(_.username) diff --git a/modules/clas/src/main/ClasApi.scala b/modules/clas/src/main/ClasApi.scala index 94404a085ef2c..b37969700376e 100644 --- a/modules/clas/src/main/ClasApi.scala +++ b/modules/clas/src/main/ClasApi.scala @@ -258,6 +258,17 @@ final class ClasApi( sendWelcomeMessage(teacher.id, user, clas)).inject(Student.WithPassword(student, password)) } + def move(s: Student.WithUser, toClas: Clas)(using teacher: Me): Fu[Option[Student]] = for + _ <- closeAccount(s) + stu = s.student.copy(id = Student.makeId(s.user.id, toClas.id), clasId = toClas.id) + moved <- colls.student.insert + .one(stu) + .inject(stu.some) + .recoverWith(lila.db.recoverDuplicateKey { _ => + student.get(toClas, s.user.id) + }) + yield moved + def manyCreate( clas: Clas, data: ClasForm.ManyNewStudent, diff --git a/modules/clas/src/main/ui/DashboardUi.scala b/modules/clas/src/main/ui/DashboardUi.scala index 7d36bbe73c43e..532c19eac7f56 100644 --- a/modules/clas/src/main/ui/DashboardUi.scala +++ b/modules/clas/src/main/ui/DashboardUi.scala @@ -139,11 +139,13 @@ final class DashboardUi(helpers: Helpers, ui: ClasUi)(using NetDomain): tr( td(userIdLink(i.userId.some)), td(i.realName), - td(if i.accepted.has(false) then "Declined" else "Pending"), + td( + if i.accepted.has(false) then trans.clas.declined.txt() else trans.clas.pending.txt() + ), td(momentFromNow(i.created.at)), td: postForm(action := routes.Clas.invitationRevoke(i.id)): - submitButton(cls := "button button-red button-empty")("Revoke") + submitButton(cls := "button button-red button-empty")(trans.site.delete()) ) ) val archivedBox = diff --git a/modules/clas/src/main/ui/StudentFormUi.scala b/modules/clas/src/main/ui/StudentFormUi.scala index 59803a9417101..38095a7e77b63 100644 --- a/modules/clas/src/main/ui/StudentFormUi.scala +++ b/modules/clas/src/main/ui/StudentFormUi.scala @@ -217,7 +217,11 @@ final class StudentFormUi(helpers: Helpers, clasUi: ClasUi, studentUi: StudentUi cls := "button button-empty button-red", title := trans.clas.closeDesc1.txt() )(trans.clas.closeStudent()) - ) + ), + a( + href := routes.Clas.studentMove(clas.id, s.user.username), + cls := "button button-empty" + )(trans.clas.moveToAnotherClass()) ) ) ) @@ -250,6 +254,32 @@ final class StudentFormUi(helpers: Helpers, clasUi: ClasUi, studentUi: StudentUi ) ) + def move(clas: Clas, students: List[Student], s: Student.WithUser, otherClasses: List[Clas])(using + Context + ) = + ClasPage(s.user.username.value, Left(clas.withStudents(students)), s.student.some)( + cls := "student-show student-edit" + ): + + val classForms: Frag = otherClasses.map: toClass => + postForm(action := routes.Clas.studentMovePost(clas.id, s.student.userId, toClass.id))( + form3.submit(toClass.name, icon = Icon.InternalArrow.some)( + cls := "yes-no-confirm button-blue button-empty", + title := trans.clas.moveToClass.txt(toClass.name) + ) + ) + + frag( + studentUi.top(clas, s), + div(cls := "box__pad")( + h2(trans.clas.moveToAnotherClass()), + classForms, + form3.actions( + a(href := routes.Clas.studentShow(clas.id, s.user.username))(trans.site.cancel()) + ) + ) + ) + def close(clas: Clas, students: List[Student], s: Student.WithUser)(using Context) = ClasPage(s.user.username.value, Left(clas.withStudents(students)), s.student.some)( cls := "student-show student-edit" diff --git a/modules/core/src/main/pref.scala b/modules/core/src/main/pref.scala index 4d1ebe3fafb37..bc2a2a26c0cc4 100644 --- a/modules/core/src/main/pref.scala +++ b/modules/core/src/main/pref.scala @@ -2,7 +2,8 @@ package lila.core package pref import lila.core.user.User -import lila.core.userId.UserId +import lila.core.userId.{ MyId, UserId } +import lila.core.game.Game trait Pref: val id: UserId @@ -22,6 +23,7 @@ trait Pref: def hasKeyboardMove: Boolean def hasVoice: Boolean + def hideRatingsInGame: Boolean def showRatings: Boolean def animationMillis: Int def animationMillisForSpeedPuzzles: Int diff --git a/modules/coreI18n/src/main/key.scala b/modules/coreI18n/src/main/key.scala index 250563331e60d..47a1a657abd23 100644 --- a/modules/coreI18n/src/main/key.scala +++ b/modules/coreI18n/src/main/key.scala @@ -282,6 +282,8 @@ object I18nKey: val `welcomeToClass`: I18nKey = "class:welcomeToClass" val `invitationToClass`: I18nKey = "class:invitationToClass" val `clickToViewInvitation`: I18nKey = "class:clickToViewInvitation" + val `pending`: I18nKey = "class:pending" + val `declined`: I18nKey = "class:declined" val `onlyVisibleToTeachers`: I18nKey = "class:onlyVisibleToTeachers" val `lastActiveDate`: I18nKey = "class:lastActiveDate" val `managed`: I18nKey = "class:managed" @@ -330,6 +332,8 @@ object I18nKey: val `anInvitationHasBeenSentToX`: I18nKey = "class:anInvitationHasBeenSentToX" val `xAlreadyHasAPendingInvitation`: I18nKey = "class:xAlreadyHasAPendingInvitation" val `xIsAKidAccountWarning`: I18nKey = "class:xIsAKidAccountWarning" + val `moveToClass`: I18nKey = "class:moveToClass" + val `moveToAnotherClass`: I18nKey = "class:moveToAnotherClass" val `nbPendingInvitations`: I18nKey = "class:nbPendingInvitations" val `nbTeachers`: I18nKey = "class:nbTeachers" val `nbStudents`: I18nKey = "class:nbStudents" @@ -1084,6 +1088,7 @@ object I18nKey: val `displayBoardResizeHandle`: I18nKey = "preferences:displayBoardResizeHandle" val `onlyOnInitialPosition`: I18nKey = "preferences:onlyOnInitialPosition" val `inGameOnly`: I18nKey = "preferences:inGameOnly" + val `exceptInGame`: I18nKey = "preferences:exceptInGame" val `chessClock`: I18nKey = "preferences:chessClock" val `tenthsOfSeconds`: I18nKey = "preferences:tenthsOfSeconds" val `whenTimeRemainingLessThanTenSeconds`: I18nKey = "preferences:whenTimeRemainingLessThanTenSeconds" diff --git a/modules/game/src/main/ui/GameUi.scala b/modules/game/src/main/ui/GameUi.scala index 3662cb28c117a..b523a95e87a74 100644 --- a/modules/game/src/main/ui/GameUi.scala +++ b/modules/game/src/main/ui/GameUi.scala @@ -20,7 +20,12 @@ final class GameUi(helpers: Helpers): private val dataTimeControl = attr("data-tc") val cgWrap = span(cls := "cg-wrap")(cgWrapContent) - def apply(pov: Pov, ownerLink: Boolean = false, tv: Boolean = false, withLink: Boolean = true)(using + def apply( + pov: Pov, + ownerLink: Boolean = false, + tv: Boolean = false, + withLink: Boolean = true + )(using ctx: Context ): Tag = renderMini( diff --git a/modules/pref/src/main/Pref.scala b/modules/pref/src/main/Pref.scala index a087d92f0d729..fc9aebf0d083e 100644 --- a/modules/pref/src/main/Pref.scala +++ b/modules/pref/src/main/Pref.scala @@ -92,7 +92,8 @@ case class Pref( def isZen = zen == Zen.YES def isZenAuto = zen == Zen.GAME_AUTO - val showRatings = ratings == Ratings.YES + def showRatings = ratings != Ratings.NO + def hideRatingsInGame = ratings == Ratings.EXCEPT_GAME def is2d = !is3d @@ -427,7 +428,16 @@ object Pref: GAME_AUTO -> "In-game only" ) - object Ratings extends BooleanPref + object Ratings: + val NO = 0 + val YES = 1 + val EXCEPT_GAME = 2 + + val choices = Seq( + NO -> "No", + YES -> "Yes", + EXCEPT_GAME -> "Except in-game" + ) val darkByDefaultSince = instantOf(2021, 11, 7, 8, 0) val systemByDefaultSince = instantOf(2022, 12, 23, 8, 0) diff --git a/modules/pref/src/main/PrefForm.scala b/modules/pref/src/main/PrefForm.scala index 11f69266103dd..65b8e91d9d516 100644 --- a/modules/pref/src/main/PrefForm.scala +++ b/modules/pref/src/main/PrefForm.scala @@ -49,7 +49,7 @@ object PrefForm: val moretime = "moretime" -> checkedNumber(Pref.Moretime.choices) val clockSound = "clockSound" -> booleanNumber val pieceNotation = "pieceNotation" -> booleanNumber - val ratings = "ratings" -> booleanNumber + val ratings = "ratings" -> checkedNumber(Pref.Ratings.choices) val flairs = "flairs" -> boolean val follow = "follow" -> booleanNumber val challenge = "challenge" -> checkedNumber(Pref.Challenge.choices) diff --git a/modules/pref/src/main/ui/AccountPref.scala b/modules/pref/src/main/ui/AccountPref.scala index f3519e4209df2..f731633254f9a 100644 --- a/modules/pref/src/main/ui/AccountPref.scala +++ b/modules/pref/src/main/ui/AccountPref.scala @@ -77,7 +77,7 @@ final class AccountPref(helpers: Helpers, helper: PrefHelper, bits: AccountUi): setting( trp.showPlayerRatings(), frag( - radios(form("ratings"), booleanChoices), + radios(form("ratings"), translatedRatingsChoices), div(cls := "help text shy", dataIcon := Icon.InfoCircle)(trp.explainShowPlayerRatings()) ), "showRatings" diff --git a/modules/pref/src/main/ui/PrefHelper.scala b/modules/pref/src/main/ui/PrefHelper.scala index 062bdc168411a..b8d3c7b087b05 100644 --- a/modules/pref/src/main/ui/PrefHelper.scala +++ b/modules/pref/src/main/ui/PrefHelper.scala @@ -20,6 +20,13 @@ trait PrefHelper: (Pref.Zen.GAME_AUTO, trans.preferences.inGameOnly.txt()) ) + def translatedRatingsChoices(using Translate) = + List( + (Pref.Ratings.NO, trans.site.no.txt()), + (Pref.Ratings.YES, trans.site.yes.txt()), + (Pref.Ratings.EXCEPT_GAME, trans.preferences.exceptInGame.txt()) + ) + def translatedBoardCoordinateChoices(using Translate) = List( (Pref.Coords.NONE, trans.site.no.txt()), diff --git a/modules/ui/src/main/Context.scala b/modules/ui/src/main/Context.scala index d26d1cb647530..b42d13faf2b3b 100644 --- a/modules/ui/src/main/Context.scala +++ b/modules/ui/src/main/Context.scala @@ -28,6 +28,7 @@ trait Context: def is[U: UserIdOf](u: U): Boolean = me.exists(_.is(u)) def isnt[U: UserIdOf](u: U): Boolean = !is(u) + def myId: Option[MyId] = me.map(_.myId) def noBlind = !blind def flash(name: String): Option[String] = req.flash.get(name) inline def noBot = !isBot diff --git a/modules/ui/src/main/helper/GameHelper.scala b/modules/ui/src/main/helper/GameHelper.scala index 090515fabfb06..a44a64ba800ad 100644 --- a/modules/ui/src/main/helper/GameHelper.scala +++ b/modules/ui/src/main/helper/GameHelper.scala @@ -48,7 +48,7 @@ trait GameHelper: user.name, user.flair.map(userFlair), withRating.option( - frag( + span(cls := "rating")( " (", player.rating.fold(frag("?")): rating => if player.provisional.yes then diff --git a/translation/source/class.xml b/translation/source/class.xml index c037cb3407514..5c2bccacb8046 100644 --- a/translation/source/class.xml +++ b/translation/source/class.xml @@ -57,6 +57,8 @@ Here is the link to access the class. One pending invitation %s pending invitations + Pending + Declined Only visible to the class teachers Active Managed @@ -114,4 +116,6 @@ It will display a horizontal line. An invitation has been sent to %s %s already has a pending invitation %1$s is a kid account and can't receive your message. You must give them the invitation URL manually: %2$s + Move to %s + Move to another class diff --git a/translation/source/preferences.xml b/translation/source/preferences.xml index 2a0040d62e1be..a89094b820e45 100644 --- a/translation/source/preferences.xml +++ b/translation/source/preferences.xml @@ -20,6 +20,7 @@ Show board resize handle Only on initial position In-game only + Except in-game Chess clock Tenths of seconds When time remaining < 10 seconds diff --git a/ui/@types/lichess/i18n.d.ts b/ui/@types/lichess/i18n.d.ts index c7c59f33e9bf0..3cf9d394c987c 100644 --- a/ui/@types/lichess/i18n.d.ts +++ b/ui/@types/lichess/i18n.d.ts @@ -471,6 +471,8 @@ interface I18n { createMultipleAccounts: string; /** Only create accounts for real students. Do not use this to make multiple accounts for yourself. You would get banned. */ createStudentWarning: string; + /** Declined */ + declined: string; /** Edit news */ editNews: string; /** Features */ @@ -515,6 +517,10 @@ interface I18n { maxStudentsNote: I18nFormat; /** Message all students about new class material */ messageAllStudents: string; + /** Move to another class */ + moveToAnotherClass: string; + /** Move to %s */ + moveToClass: I18nFormat; /** You can also %s to create multiple Lichess accounts from a list of student names. */ multipleAccsFormDescription: I18nFormat; /** N/A */ @@ -555,6 +561,8 @@ interface I18n { overview: string; /** Password: %s */ passwordX: I18nFormat; + /** Pending */ + pending: string; /** Private. Will never be shown outside the class. Helps you remember who the student is. */ privateWillNeverBeShown: string; /** Progress */ @@ -2095,6 +2103,8 @@ interface I18n { displayBoardResizeHandle: string; /** Drag a piece */ dragPiece: string; + /** Except in-game */ + exceptInGame: string; /** Can be disabled during a game with the board menu */ explainCanThenBeTemporarilyDisabled: string; /** Hold the key while promoting to temporarily disable auto-promotion */ diff --git a/ui/common/css/component/_mini-game.scss b/ui/common/css/component/_mini-game.scss index 5a17cd71cf689..5f419bf712ce6 100644 --- a/ui/common/css/component/_mini-game.scss +++ b/ui/common/css/component/_mini-game.scss @@ -44,6 +44,9 @@ margin-inline-start: 1ch; font-size: 0.9em; + body.no-rating & { + display: none; + } } &__clock { diff --git a/ui/common/css/component/_power-tip.scss b/ui/common/css/component/_power-tip.scss index 29066ac33ddc1..b46e114d311b7 100644 --- a/ui/common/css/component/_power-tip.scss +++ b/ui/common/css/component/_power-tip.scss @@ -67,6 +67,9 @@ padding: 2px 3px; text-align: left; } + body.no-rating & { + display: none; + } } &__warning { diff --git a/ui/common/css/component/_user-link.scss b/ui/common/css/component/_user-link.scss index 0e46ff4907ae6..5b43bb0856970 100644 --- a/ui/common/css/component/_user-link.scss +++ b/ui/common/css/component/_user-link.scss @@ -47,6 +47,11 @@ content: $licon-Agent; } } + .rating { + body.no-rating & { + display: none; + } + } } a.user-link:hover { diff --git a/ui/round/css/_user.scss b/ui/round/css/_user.scss index 9f557e045f141..d0b3512c6113a 100644 --- a/ui/round/css/_user.scss +++ b/ui/round/css/_user.scss @@ -26,6 +26,9 @@ margin: 0 0.25em 0 0.3em; color: $c-font-dim; letter-spacing: -0.5px; + body.no-rating & { + display: none; + } } .line {