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 {