make blogs more discoverable

This commit is contained in:
Jonathan Gamble
2025-06-13 22:31:41 -05:00
parent ba41542ade
commit bc6d5a87b5
60 changed files with 1400 additions and 1128 deletions
+1 -1
View File
@@ -1 +1 @@
v22.17.0
v24.1.0
-1
View File
@@ -1 +0,0 @@
update-notifier=false
+2 -1
View File
@@ -36,7 +36,8 @@ final class Dev(env: Env) extends LilaController(env):
env.relay.proxyDomainRegex,
env.relay.proxyHostPort,
env.relay.proxyCredentials,
env.ublog.automod.promptSetting
env.ublog.automod.promptSetting,
env.ublog.automod.temperatureSetting
)
def settings = Secure(_.Settings) { _ ?=> _ ?=>
+87 -44
View File
@@ -3,18 +3,24 @@ package controllers
import scala.annotation.nowarn
import play.api.i18n.Lang
import play.api.mvc.Result
import play.api.libs.json.*
import lila.app.{ *, given }
import scalalib.model.Language
import lila.i18n.{ LangList, LangPicker }
import lila.report.Suspect
import lila.ublog.{ UblogBlog, UblogPost, UblogRank, UblogBestOf }
import lila.ublog.{ UblogBlog, UblogPost, UblogByMonth }
import lila.ublog.UblogAutomod.Quality
import lila.core.ublog.BlogsBy
import lila.core.i18n.toLanguage
//import lila.ublog.UblogAutomod
import lila.ublog.UblogForm.ModPostData
final class Ublog(env: Env) extends LilaController(env):
import views.ublog.ui.{ editUrlOfPost, urlOfPost, urlOfBlog }
import scalalib.paginator.Paginator.given
import Quality.*
def index(username: UserStr, page: Int) = Open:
NotForKidsUnlessOfficial(username):
@@ -46,15 +52,26 @@ final class Ublog(env: Env) extends LilaController(env):
(canViewBlogOf(user, blog) && post.canView).so:
for
otherPosts <- env.ublog.api.recommend(UblogBlog.Id.User(user.id), post)
liked <- ctx.user.so(env.ublog.rank.liked(post))
liked <- ctx.user.so(env.ublog.api.liked(post))
followed <- ctx.userId.so(env.relation.api.fetchFollows(_, user.id))
prefFollowable <- ctx.isAuth.so(env.pref.api.followable(user.id))
blocked <- ctx.userId.so(env.relation.api.fetchBlocks(user.id, _))
isInCarousel <- isGrantedOpt(_.ModerateBlog).so(env.ublog.api.carousel().map(_.has(post.id)))
followable = prefFollowable && !blocked
markup <- env.ublog.markup(post)
viewedPost = env.ublog.viewCounter(post, ctx.ip)
page <- renderPage:
views.ublog.post.page(user, blog, viewedPost, markup, otherPosts, liked, followable, followed)
views.ublog.post.page(
user,
blog,
viewedPost,
markup,
otherPosts,
liked,
followable,
followed,
isInCarousel
)
yield Ok(page)
def discuss(id: UblogPostId) = Open:
@@ -163,7 +180,7 @@ final class Ublog(env: Env) extends LilaController(env):
def like(id: UblogPostId, v: Boolean) = Auth { ctx ?=> _ ?=>
NoBot:
NotForKids:
env.ublog.rank.like(id, v).map(Ok(_))
env.ublog.api.like(id, v).map(Ok(_))
}
def redirect(id: UblogPostId) = Open:
@@ -178,28 +195,46 @@ final class Ublog(env: Env) extends LilaController(env):
for
user <- env.user.repo.byId(blog.userId).orFail("Missing blog user!").dmap(Suspect.apply)
_ <- env.ublog.api.setModTier(blog.id, tier)
_ <- env.ublog.rank.recomputeRankOfAllPostsOfBlog(blog.id)
_ <- env.mod.logApi.blogTier(user, UblogRank.Tier.name(tier))
_ <- env.mod.logApi.blogTier(user, UblogBlog.Tier.name(tier))
yield Redirect(urlOfBlog(blog)).flashSuccess
)
}
def modAdjust(postId: UblogPostId) = SecureBody(_.ModerateBlog) { ctx ?=> me ?=>
def modShowCarousel = Secure(_.ModerateBlog) { ctx ?=> me ?=>
env.ublog.api
.carousel()
.flatMap: carousel =>
Ok.page(views.ublog.ui.modShowCarousel(carousel))
}
def modPull(postId: UblogPostId) = Secure(_.ModerateBlog) { ctx ?=> me ?=>
Found(env.ublog.api.getPost(postId)): post =>
bindForm(lila.ublog.UblogForm.adjust)(
_ => Redirect(urlOfPost(post)).flashFailure,
(pinned, tier, rankAdjustDays, assess) =>
post.id.pp
env.ublog.api
.setFeatured(post, ModPostData(featured = false.some))
.flatMap: _ =>
logModAction(post, "pull from carousel")
Redirect(routes.Ublog.modShowCarousel)
}
def modPost(postId: UblogPostId) = SecureBody(parse.json)(_.ModerateBlog) { ctx ?=> me ?=>
Found(env.ublog.api.getPost(postId)): post =>
ctx.body.body.validate(using ModPostData.reads) match
case JsError(errors) => fuccess(BadRequest(errors.flatMap(_._2.map(_.message)).mkString(", ")))
case JsSuccess(data, _) =>
for
_ <- env.ublog.api.setModTier(post.blog, tier)
_ <- env.ublog.api.setModAdjust(post.id, ~rankAdjustDays, pinned, assess)
_ <- logModAction(
post,
s"Set tier: $tier, pinned: $pinned, post adjust: ${~rankAdjustDays} days",
logIncludingMe = true
mod <- env.ublog.api.setModAdjust(post, data)
featured <- env.ublog.api.setFeatured(post, data)
carousel <- env.ublog.api.carousel()
yield
if data.hasUpdates then logModAction(post, data.text)
Ok.snip(
views.ublog.post.modTools(
post.copy(automod = mod.orElse(post.automod), featured = featured.orElse(post.featured)),
carousel.has(post.id)
)
)
_ <- env.ublog.rank.recomputeRankOfAllPostsOfBlog(post.blog)
yield Redirect(urlOfPost(post)).flashSuccess
)
}
def image(id: UblogPostId) = AuthBody(parse.multipartFormData) { ctx ?=> me ?=>
@@ -228,34 +263,34 @@ final class Ublog(env: Env) extends LilaController(env):
env.ublog.paginator.liveByFollowed(me, page).map(views.ublog.ui.friends)
}
def communityLang(language: Language, page: Int = 1) = Open:
def communityLang(language: Language, filter: Boolean, page: Int = 1) = Open:
import LangPicker.ByHref
LangPicker.byHref(language, ctx.req) match
case ByHref.NotFound => Redirect(routes.Ublog.communityAll(page))
case ByHref.Redir(language) => Redirect(routes.Ublog.communityLang(language, page))
case ByHref.Refused(lang) => communityIndex(lang.some, page)
case ByHref.NotFound => Redirect(routes.Ublog.communityAll(filter, page))
case ByHref.Redir(language) => Redirect(routes.Ublog.communityLang(language, filter, page))
case ByHref.Refused(lang) => communityIndex(lang.some, filter, page)
case ByHref.Found(lang) =>
if ctx.isAuth then communityIndex(lang.some, page)
else communityIndex(lang.some, page)(using ctx.withLang(lang))
if ctx.isAuth then communityIndex(lang.some, filter, page)
else communityIndex(lang.some, filter, page)(using ctx.withLang(lang))
def communityAll(page: Int) = Open:
communityIndex(none, page)
def communityAll(filter: Boolean, page: Int) = Open:
communityIndex(none, filter, page)
private def communityIndex(l: Option[Lang], page: Int)(using ctx: Context) =
private def communityIndex(l: Option[Lang], filter: Boolean, page: Int)(using Context) =
NotForKids:
Reasonable(page, Max(50)):
Reasonable(page, Max(200)):
pageHit
Ok.async:
val language = l.map(toLanguage)
env.ublog.paginator
.liveByCommunity(language, page)
.liveByCommunity(language, filter, page)
.map:
views.ublog.community(language, _)
views.ublog.community(language, filter, _)
def communityAtom(language: Language) = Anon:
val found: Option[Lang] = LangList.popularNoRegion.find(l => toLanguage(l) == language)
env.ublog.paginator
.liveByCommunity(found.map(toLanguage), page = 1)
.liveByCommunity(found.map(toLanguage), true, page = 1)
.map: posts =>
Ok.snip(views.ublog.ui.atom.community(language, posts.currentPageResults)).as(XML)
@@ -271,29 +306,28 @@ final class Ublog(env: Env) extends LilaController(env):
Ok.async:
env.ublog.topic.withPosts.map(views.ublog.ui.topics)
def topic(str: String, page: Int, byDate: Boolean) = Open:
def topic(str: String, filter: Boolean, by: BlogsBy, page: Int) = Open:
NotForKids:
Reasonable(page, Max(50)):
Found(lila.ublog.UblogTopic.fromUrl(str)): top =>
Ok.async:
env.ublog.paginator
.liveByTopic(top, page, byDate)
.liveByTopic(top, filter, by, page)
.map:
views.ublog.ui.topic(top, _, byDate)
views.ublog.ui.topic(top, filter, by, _)
def bestOfYear(page: Int) = Open:
NotForKids:
Ok.async:
env.ublog.bestOf.liveByYear(page).map(views.ublog.ui.year)
def thisMonth(filter: Boolean, by: BlogsBy, page: Int) =
val now = nowInstant.date
byMonth(now.getYear(), now.getMonth().getValue(), filter, by, page)
def bestOfMonth(year: Int, month: Int, page: Int) = Open:
def byMonth(year: Int, month: Int, filter: Boolean, by: BlogsBy, page: Int) = Open: ctx ?=>
NotForKids:
Reasonable(page, Max(20)):
Found(UblogBestOf.readYearMonth(year, month)): yearMonth =>
Reasonable(page, Max(50)):
Found(UblogByMonth.readYearMonth(year, month)): yearMonth =>
Ok.async:
env.ublog.paginator
.liveByMonth(yearMonth, page)
.map(views.ublog.ui.month(yearMonth, _))
.liveByMonth(yearMonth, filter, by, page)
.map(views.ublog.ui.month(yearMonth, filter, by, _))
def userAtom(username: UserStr) = Anon:
Found(env.user.repo.enabledById(username)): user =>
@@ -306,6 +340,15 @@ final class Ublog(env: Env) extends LilaController(env):
Found(env.ublog.api.getByPrismicId(id)): post =>
Redirect(routes.Ublog.post(UserName.lichess, post.slug, post.id), MOVED_PERMANENTLY)
def search(text: String, by: BlogsBy, page: Int) = Open: ctx ?=>
val queryText = text.take(100).trim()
NotForKids:
for
ids <- env.ublog.search.fetchResults(queryText, by, Weak.some, page)
posts <- ids.mapFutureList(env.ublog.api.searchResultPreviews)
page <- renderPage(views.ublog.ui.search(queryText, by, posts.some))
yield Ok(page)
private def isBlogVisible(user: UserModel, blog: UblogBlog) = user.enabled.yes && blog.visible
private def NotForKidsUnlessOfficial(username: UserStr)(f: => Fu[Result])(using Context): Fu[Result] =
+1 -1
View File
@@ -5,7 +5,7 @@ import play.api.data.Form
import lila.app.UiEnv.{ *, given }
import lila.clas.{ Clas, Student }
lazy val ui = lila.clas.ui.ClasUi(helpers)(views.mod.ui.menu("search"))
lazy val ui = lila.clas.ui.ClasUi(helpers)(lila.ui.bits.modMenu("search"))
private lazy val dashUi = lila.clas.ui.DashboardUi(helpers, ui)
export dashUi.student.apply as studentDashboard
+5 -4
View File
@@ -10,6 +10,7 @@ import lila.mod.IpRender.RenderIp
import lila.security.IpTrust
import lila.user.WithPerfsAndEmails
import lila.mod.ModUserSearchResult
import lila.ui.bits.modMenu
object search:
@@ -18,7 +19,7 @@ object search:
.css("mod.misc")
.js(Esm("mod.search")):
main(cls := "page-menu")(
views.mod.ui.menu("search"),
modMenu("search"),
div(cls := "mod-search page-menu__content box")(
h1(cls := "box__top")("Search users"),
st.form(cls := "search box__pad", action := routes.Mod.search, method := "GET")(
@@ -47,7 +48,7 @@ object search:
.css("mod.misc")
.js(Esm("mod.search")):
main(cls := "page-menu")(
views.mod.ui.menu("search"),
modMenu("search"),
div(cls := "mod-search page-menu__content box")(
boxTop(
h1("Fingerprint: ", fh.value),
@@ -86,7 +87,7 @@ object search:
.css("mod.misc")
.js(Esm("mod.search")):
main(cls := "page-menu")(
views.mod.ui.menu("search"),
modMenu("search"),
div(cls := "mod-search page-menu__content box")(
boxTop(
h1("IP address: ", renderIp(address)),
@@ -122,4 +123,4 @@ object search:
export views.clas.ui.search.teacher
def notes(query: String, pager: Paginator[lila.user.Note])(using Context) =
views.user.noteUi.search(query, pager, ui.menu("notes"))
views.user.noteUi.search(query, pager, modMenu("notes"))
+1 -1
View File
@@ -23,7 +23,7 @@ private lazy val showUi = TournamentShow(helpers, views.gathering)(
export showUi.faq.page as faq
lazy val form = TournamentForm(helpers, showUi)(
modMenu = views.mod.ui.menu("tour"),
modMenu = lila.ui.bits.modMenu("tour"),
views.setup.translatedVariantChoicesWithVariantsById
)
+5 -6
View File
@@ -10,10 +10,7 @@ import lila.core.i18n.toLanguage
lazy val ui = lila.ublog.ui.UblogUi(helpers, views.atomUi)(picfitUrl)
lazy val post = lila.ublog.ui.UblogPostUi(helpers, ui)(
ublogRank = env.ublog.rank,
connectLinks = views.bits.connectLinks
)
lazy val post = lila.ublog.ui.UblogPostUi(helpers, ui)(connectLinks = views.bits.connectLinks)
lazy val form = lila.ublog.ui.UblogFormUi(helpers, ui)(
renderCaptcha = (form, captcha) =>
@@ -22,10 +19,12 @@ lazy val form = lila.ublog.ui.UblogFormUi(helpers, ui)(
views.captcha(form, _)
)
def community(language: Option[Language], posts: Paginator[UblogPost.PreviewPost])(using ctx: Context) =
def community(language: Option[Language], filter: Boolean, posts: Paginator[UblogPost.PreviewPost])(using
ctx: Context
) =
val langSelections: List[(Language, String)] = (Language("all"), trans.site.allLanguages.txt()) ::
lila.i18n.LangPicker
.sortFor(LangList.popularNoRegion, ctx.req)
.map: l =>
toLanguage(l) -> LangList.name(l)
ui.community(language, posts, langSelections)
ui.community(language, filter, posts, langSelections)
+6 -5
View File
@@ -1,6 +1,7 @@
package views
import lila.app.UiEnv.{ *, given }
import lila.ui.bits.modMenu
export lila.web.ui.bits
val captcha = lila.web.ui.CaptchaUi(helpers)
@@ -19,7 +20,7 @@ val coordinate = lila.coordinate.ui.CoordinateUi(helpers)
val atomUi = lila.ui.AtomUi(netConfig.baseUrl)
val irwin = lila.irwin.IrwinUi(helpers)(menu = mod.ui.menu)
val irwin = lila.irwin.IrwinUi(helpers)(menu = modMenu)
val dgt = lila.web.ui.DgtUi(helpers)
@@ -37,9 +38,9 @@ val feed =
env.executor
)
val cms = lila.cms.ui.CmsUi(helpers)(mod.ui.menu("cms"))
val cms = lila.cms.ui.CmsUi(helpers)(modMenu("cms"))
val event = lila.event.ui.EventUi(helpers)(mod.ui.menu("event"))(using env.executor)
val event = lila.event.ui.EventUi(helpers)(modMenu("event"))(using env.executor)
val userTournament = lila.tournament.ui.UserTournament(helpers, tournament.ui)
@@ -53,7 +54,7 @@ object account:
val practice = lila.practice.ui.PracticeUi(helpers)(
csp = analyse.ui.bits.cspExternalEngine,
views.analyse.ui.explorerAndCevalConfig,
modMenu = mod.ui.menu("practice")
modMenu = modMenu("practice")
)
object forum:
@@ -85,7 +86,7 @@ val racer = lila.racer.ui.RacerUi(helpers)
val challenge = lila.challenge.ui.ChallengeUi(helpers)
val dev = lila.web.ui.DevUi(helpers)(mod.ui.menu)
val dev = lila.web.ui.DevUi(helpers)(modMenu)
val jsBot = lila.jsBot.ui.JsBotUi(helpers)
+2 -4
View File
@@ -1,12 +1,10 @@
{
"name": "bin",
"version": "1.0.0",
"description": "",
"type": "module",
"dependencies": {
"@types/node": "24.0.1",
"fast-xml-parser": "^4.5.3",
"mongodb": "6.16.0"
},
"keywords": [],
"author": "",
"license": "ISC"
}
+1 -1
View File
@@ -179,7 +179,7 @@ lazy val feed = module("feed",
)
lazy val ublog = module("ublog",
Seq(memo, ui),
Seq(memo, ui, search),
Seq(bloomFilter)
)
+8 -4
View File
@@ -333,10 +333,14 @@ forumSearch {
index = forum
paginator.max_per_page = 10
}
ublog.automod {
apiKey = ""
url = "https://api.together.xyz/v1/chat/completions"
model = "Qwen/Qwen3-235B-A22B-fp8-tput"
ublog {
search_page_size = 12
carousel_size = 9
automod {
apiKey = ""
url = "https://api.together.xyz/v1/chat/completions"
model = "Qwen/Qwen3-235B-A22B-fp8-tput"
}
}
message {
thread.max_per_page = 30
+27 -23
View File
@@ -69,23 +69,6 @@ GET /insights/:username/:metric/:dimension/*filters controllers.Insight.path(u
GET /@/:username/tournaments/:path controllers.UserTournament.path(username: UserStr, path, page: Int ?= 1)
GET /@/:username/simuls/hosted controllers.Simul.byUser(username: UserStr, page: Int ?= 1)
# User blog
GET /@/:username/blog controllers.Ublog.index(username: UserStr, page: Int ?= 1)
GET /@/:username/blog/:slug/:id controllers.Ublog.post(username: UserStr, slug, id: UblogPostId)
GET /@/:username/blog/drafts controllers.Ublog.drafts(username: UserStr, page: Int ?= 1)
GET /@/:username/blog/new controllers.Ublog.form(username: UserStr)
POST /@/:username/blog/new controllers.Ublog.create(username: UserStr)
GET /@/:username/blog.atom controllers.Ublog.userAtom(username: UserStr)
GET /ublog/$id<\w{8}>/discuss controllers.Ublog.discuss(id: UblogPostId)
GET /ublog/$id<\w{8}>/redirect controllers.Ublog.redirect(id: UblogPostId)
GET /ublog/$id<\w{8}>/edit controllers.Ublog.edit(id: UblogPostId)
POST /ublog/$id<\w{8}>/edit controllers.Ublog.update(id: UblogPostId)
POST /ublog/$id<\w{8}>/del controllers.Ublog.delete(id: UblogPostId)
POST /ublog/$id<\w{8}>/like controllers.Ublog.like(id: UblogPostId, v: Boolean)
POST /ublog/:blogId/tier controllers.Ublog.setTier(blogId: String)
POST /ublog/$id<\w{8}>/adjust controllers.Ublog.modAdjust(id: UblogPostId)
POST /upload/image/ublog/$id<\w{8}> controllers.Ublog.image(id: UblogPostId)
# User
GET /api/stream/:username/mod controllers.User.mod(username: UserStr)
POST /@/:username/note controllers.User.writeNote(username: UserStr)
@@ -120,21 +103,41 @@ GET /api/fide/player controllers.Fide.apiSearch(q: String)
GET /dasher controllers.Dasher.get
# Blog
# Blog/Ublog
GET /blog controllers.Main.movedPermanently(to = "/@/Lichess/blog")
GET /blog/topic controllers.Ublog.topics
GET /blog/topic/:topic controllers.Ublog.topic(topic, page: Int ?= 1, byDate: Boolean ?= false)
GET /blog/best-of controllers.Ublog.bestOfYear(page: Int ?= 1)
GET /blog/best-of/:year/:month controllers.Ublog.bestOfMonth(year: Int, month: Int, page: Int ?= 1)
GET /blog/topic/:topic controllers.Ublog.topic(topic, filter: Boolean ?= true, by: BlogsBy ?= BlogsBy.Newest, page: Int ?= 1)
GET /blog/monthly controllers.Ublog.thisMonth(filter: Boolean ?= true, by: BlogsBy ?= BlogsBy.Newest, page: Int ?= 1)
GET /blog/monthly/:year/:month controllers.Ublog.byMonth(year: Int, month: Int, filter: Boolean ?= true, by: BlogsBy ?= BlogsBy.Newest, page: Int ?= 1)
GET /blog.atom controllers.Main.movedPermanently(to = "/@/Lichess/blog.atom")
GET /blog/friends controllers.Ublog.friends(page: Int ?= 1)
GET /blog/liked controllers.Ublog.liked(page: Int ?= 1)
GET /blog/community controllers.Ublog.communityAll(page: Int ?= 1)
GET /$lang<\w\w\w?>/blog/community controllers.Ublog.communityLang(lang: Language, page: Int ?= 1)
GET /blog/search controllers.Ublog.search(text ?= "", by: BlogsBy ?= BlogsBy.Score, page: Int ?= 1)
GET /blog/community controllers.Ublog.communityAll(filter: Boolean ?= true, page: Int ?= 1)
GET /$lang<\w\w\w?>/blog/community controllers.Ublog.communityLang(lang: Language, filter: Boolean ?= true, page: Int ?= 1)
GET /blog/community.atom controllers.Ublog.communityAtom(lang: Language ?= Language("all"))
GET /blog/community/$lang<[\w-]{2,6}>.atom controllers.Ublog.communityAtom(lang: Language)
GET /blog/:id/:slug controllers.Ublog.historicalBlogPost(id, slug)
GET /@/:username/blog controllers.Ublog.index(username: UserStr, page: Int ?= 1)
GET /@/:username/blog/:slug/:id controllers.Ublog.post(username: UserStr, slug, id: UblogPostId)
GET /@/:username/blog/drafts controllers.Ublog.drafts(username: UserStr, page: Int ?= 1)
GET /@/:username/blog/new controllers.Ublog.form(username: UserStr)
POST /@/:username/blog/new controllers.Ublog.create(username: UserStr)
GET /@/:username/blog.atom controllers.Ublog.userAtom(username: UserStr)
GET /ublog/$id<\w{8}>/discuss controllers.Ublog.discuss(id: UblogPostId)
GET /ublog/$id<\w{8}>/redirect controllers.Ublog.redirect(id: UblogPostId)
GET /ublog/$id<\w{8}>/edit controllers.Ublog.edit(id: UblogPostId)
POST /ublog/$id<\w{8}>/edit controllers.Ublog.update(id: UblogPostId)
POST /ublog/$id<\w{8}>/del controllers.Ublog.delete(id: UblogPostId)
POST /ublog/$id<\w{8}>/like controllers.Ublog.like(id: UblogPostId, v: Boolean)
POST /ublog/:blogId/tier controllers.Ublog.setTier(blogId: String)
POST /ublog/$id<\w{8}>/adjust controllers.Ublog.modPost(id: UblogPostId)
POST /ublog/$id<\w{8}>/pull controllers.Ublog.modPull(id: UblogPostId)
GET /ublog/carousel controllers.Ublog.modShowCarousel
POST /upload/image/ublog/$id<\w{8}> controllers.Ublog.image(id: UblogPostId)
# Feed
GET /feed controllers.Feed.index(page: Int ?= 1)
GET /feed/new controllers.Feed.createForm
POST /feed/new controllers.Feed.create
@@ -143,6 +146,7 @@ POST /feed/:id/edit controllers.Feed.update(id)
POST /feed/:id/delete controllers.Feed.delete(id)
GET /feed.atom controllers.Feed.atom
# Opening
GET /opening controllers.Opening.index(q: Option[String] ?= None)
POST /opening/config/:key controllers.Opening.config(key)
POST /opening/wiki/:key/:moves controllers.Opening.wikiWrite(key, moves)
+3 -3
View File
@@ -676,9 +676,9 @@ object mon:
def create(user: String) = counter("ublog.create").withTag("user", user)
def view(user: String) = counter("ublog.view").withTag("user", user)
object automod:
val request = future("ublog.automod.request")
def classification(c: String) = counter("ublog.automod.classification").withTag("classification", c)
def flagged(f: Boolean) = counter("ublog.automod.flagged").withTag("flagged", f)
val request = future("ublog.automod.request")
def quality(c: String) = counter("ublog.automod.quality").withTag("quality", c)
def flagged(f: Boolean) = counter("ublog.automod.flagged").withTag("flagged", f)
object picfit:
def uploadTime(user: String) = future("picfit.upload.time", tags("user" -> user))
def uploadSize(user: String) = histogram("picfit.upload.size").withTag("user", user)
+2 -1
View File
@@ -34,5 +34,6 @@ trait IrcApi:
slug: String,
title: String,
intro: String,
topic: String
topic: String,
automod: Option[String]
): Funit
+6
View File
@@ -19,3 +19,9 @@ object UblogPost:
trait UblogApi:
def liveLightsByIds(ids: List[UblogPostId]): Fu[List[UblogPost.LightPost]]
enum BlogsBy:
case Newest, Oldest, Score, Likes
object BlogsBy:
def fromName(name: String): Option[BlogsBy] = BlogsBy.values.find(_.toString.equalsIgnoreCase(name))
+6 -5
View File
@@ -2846,13 +2846,14 @@ object I18nKey:
val `xArena`: I18nKey = "tourname:xArena"
object ublog:
val `communityBlogs`: I18nKey = "ublog:communityBlogs"
val `friendBlogs`: I18nKey = "ublog:friendBlogs"
val `lichessBlog`: I18nKey = "ublog:lichessBlog"
val `community`: I18nKey = "ublog:community"
val `byMonth`: I18nKey = "ublog:byMonth"
val `byTopic`: I18nKey = "ublog:byTopic"
val `byLichess`: I18nKey = "ublog:byLichess"
val `myFriends`: I18nKey = "ublog:myFriends"
val `myLikes`: I18nKey = "ublog:myLikes"
val `myBlog`: I18nKey = "ublog:myBlog"
val `likedBlogs`: I18nKey = "ublog:likedBlogs"
val `blogTopics`: I18nKey = "ublog:blogTopics"
val `lichessOfficialBlog`: I18nKey = "ublog:lichessOfficialBlog"
val `continueReadingPost`: I18nKey = "ublog:continueReadingPost"
val `lichessBlogPostsFromXYear`: I18nKey = "ublog:lichessBlogPostsFromXYear"
val `previousBlogPosts`: I18nKey = "ublog:previousBlogPosts"
+3 -2
View File
@@ -96,11 +96,12 @@ final class IrcApi(
slug: String,
title: String,
intro: String,
topic: String
topic: String,
automod: Option[String]
): Funit =
zulip(_.blog, topic):
val link = markdown.lichessLink(s"/@/${user.name}/blog/$slug/$id", title)
s":note: $link $intro - by ${markdown.userLink(user)}"
s":note: $link $intro - by ${markdown.userLink(user)}${~automod.map(n => s"\n$n")}"
def openingEdit(user: LightUser, opening: String, moves: String): Funit =
zulip(_.content, "/opening edits"):
+2 -2
View File
@@ -22,7 +22,7 @@ final class GamifyUi(helpers: Helpers, modUi: ModUi):
Page(title).css("mod.gamify"):
main(cls := "page-menu")(
modUi.menu("gamify"),
bits.modMenu("gamify"),
div(id := "mod-gamify", cls := "page-menu__content index box")(
h1(cls := "box__top")(title),
div(cls := "champs")(
@@ -56,7 +56,7 @@ final class GamifyUi(helpers: Helpers, modUi: ModUi):
val title = s"Moderators of the ${period.name}"
Page(title).css("mod.gamify"):
main(cls := "page-menu")(
modUi.menu("gamify"),
bits.modMenu("gamify"),
div(id := "mod-gamify", cls := "page-menu__content box")(
boxTop(
h1(
+11 -48
View File
@@ -8,6 +8,7 @@ import lila.core.perf.UserWithPerfs
import lila.core.perm.Permission
import lila.mod.ModActivity.{ Period, Who }
import lila.ui.*
import lila.ui.bits.modMenu
import lila.report.Mod
@@ -40,8 +41,8 @@ final class ModUi(helpers: Helpers):
def logs(logs: List[lila.mod.Modlog], mod: Option[Mod], query: Option[UserStr])(using Context) =
Page("Mod logs").css("mod.misc"):
main(cls := "page-menu")(
menu("log"),
main(cls := "page-menu.modMenu")(
modMenu("log"),
div(id := "modlog_table", cls := "page-menu__content box")(
boxTop(cls := "box__top")(
h1(mod.fold(frag("All logs"))(of => span("Logs of ", userLink(of.user)))),
@@ -130,8 +131,8 @@ final class ModUi(helpers: Helpers):
def presets(group: String, form: Form[?])(using Context) =
Page(s"$group presets").css("mod.misc", "bits.form3"):
main(cls := "page-menu")(
menu("presets"),
main(cls := "page-menu.modMenu")(
modMenu("presets"),
div(cls := "page-menu__content box box-pad mod-presets")(
boxTop(
h1(
@@ -164,8 +165,8 @@ final class ModUi(helpers: Helpers):
Page("Email confirmation")
.css("mod.misc")
.js(Esm("mod.emailConfirmation")):
main(cls := "page-menu")(
menu("email"),
main(cls := "page-menu.modMenu")(
modMenu("email"),
div(cls := "mod-confirm page-menu__content box box-pad")(
h1(cls := "box__top")("Confirm a user email"),
p(
@@ -216,8 +217,8 @@ final class ModUi(helpers: Helpers):
Page("Queues stats")
.css("mod.activity")
.js(PageModule("mod.activity", Json.obj("op" -> "queues", "data" -> p.json))):
main(cls := "page-menu")(
menu("queues"),
main(cls := "page-menu.modMenu")(
modMenu("queues"),
div(cls := "page-menu__content index box mod-queues")(
boxTop(
h1(
@@ -266,7 +267,7 @@ final class ModUi(helpers: Helpers):
.css("mod.activity")
.js(PageModule("mod.activity", Json.obj("op" -> "activity", "data" -> ModActivity.json(p)))):
main(cls := "page-menu")(
menu("activity"),
modMenu("activity"),
div(cls := "page-menu__content index box mod-activity")(
boxTop(h1(whoSelector, " activity this ", periodSelector)),
div(cls := "chart chart-reports"),
@@ -274,45 +275,7 @@ final class ModUi(helpers: Helpers):
)
)
def reportMenu(using Context) = menu("report")
def menu(active: String)(using ctx: Context): Frag = ctx.me.foldUse(emptyFrag): me ?=>
lila.ui.bits.pageMenuSubnav(
Granter(_.SeeReport)
.option(a(cls := active.active("report"), href := routes.Report.list)("Reports")),
Granter(_.PublicChatView)
.option(a(cls := active.active("public-chat"), href := routes.Mod.publicChat)("Public Chats")),
Granter(_.GamifyView)
.option(a(cls := active.active("activity"), href := routes.Mod.activity)("Mod activity")),
Granter(_.GamifyView)
.option(a(cls := active.active("queues"), href := routes.Mod.queues("month"))("Queues stats")),
Granter(_.GamifyView)
.option(a(cls := active.active("gamify"), href := routes.Mod.gamify)("Hall of fame")),
Granter(_.GamifyView)
.option(a(cls := active.active("log"), href := routes.Mod.log(me.username.some))("Mod logs")),
Granter(_.UserSearch)
.option(a(cls := active.active("search"), href := routes.Mod.search)("Search users")),
Granter(_.Admin).option(a(cls := active.active("notes"), href := routes.Mod.notes())("Mod notes")),
Granter(_.SetEmail)
.option(a(cls := active.active("email"), href := routes.Mod.emailConfirm)("Email confirm")),
Granter(_.Pages).option(a(cls := active.active("cms"), href := routes.Cms.index)("Pages")),
Granter(_.PracticeConfig)
.option(a(cls := active.active("practice"), href := routes.Practice.config)("Practice")),
Granter(_.ManageTournament)
.option(a(cls := active.active("tour"), href := routes.TournamentCrud.index(1))("Tournaments")),
Granter(_.ManageEvent)
.option(a(cls := active.active("event"), href := routes.Event.manager)("Events")),
Granter(_.MarkEngine)
.option(a(cls := active.active("irwin"), href := routes.Irwin.dashboard)("Irwin dashboard")),
Granter(_.MarkEngine)
.option(a(cls := active.active("kaladin"), href := routes.Irwin.kaladin)("Kaladin dashboard")),
Granter(_.Admin).option(a(cls := active.active("mods"), href := routes.Mod.table)("Mods")),
Granter(_.Presets)
.option(a(cls := active.active("presets"), href := routes.Mod.presets("PM"))("Msg presets")),
Granter(_.Settings)
.option(a(cls := active.active("setting"), href := routes.Dev.settings)("Settings")),
Granter(_.Cli).option(a(cls := active.active("cli"), href := routes.Dev.cli)("CLI"))
)
def reportMenu(using Context) = modMenu("report")
def modUserSearchResult(r: ModUserSearchResult) =
div(cls := "box__pad")(
+1 -1
View File
@@ -91,7 +91,7 @@ final class ModUserTableUi(helpers: Helpers, modUi: ModUi):
def mods(users: List[User])(using Context) =
Page("All mods").css("mod.misc"):
main(cls := "page-menu")(
modUi.menu("mods"),
bits.modMenu("mods"),
div(id := "mod_table", cls := "page-menu__content box")(
h1(cls := "box__top")("All mods"),
st.table(cls := "slist slist-pad sortable")(
+1 -1
View File
@@ -17,7 +17,7 @@ final class PublicChatUi(helpers: Helpers, modUi: ModUi)(highlightBad: String =>
.css("mod.publicChats")
.js(Esm("bits.publicChats")):
main(cls := "page-menu")(
modUi.menu("public-chat"),
bits.modMenu("public-chat"),
div(id := "comm-wrap")(
div(id := "communication", cls := "page-menu__content public-chat box box-pad")(
h2("Tournament Chats"),
@@ -29,6 +29,7 @@ class LilaSearchClient(client: SearchClient)(using Executor) extends SearchClien
extension (query: Query)
def index: String = query match
case _: Query.Forum => "forum"
case _: Query.Ublog => "ublog"
case _: Query.Game => "game"
case _: Query.Study => "study"
case _: Query.Team => "team"
+22 -25
View File
@@ -2,11 +2,22 @@ package lila.ublog
import com.github.blemale.scaffeine.AsyncLoadingCache
import com.softwaremill.macwire.*
import play.api.{ ConfigLoader, Configuration }
import lila.core.config.*
import lila.db.dsl.Coll
import lila.common.autoconfig.{ *, given }
import lila.common.config.given
import lila.common.Bus
@Module
final private class UblogConfig(
@ConfigName("search_page_size") val searchPageSize: MaxPerPage,
@ConfigName("carousel_size") val carouselSize: Int,
@ConfigName("automod.url") val automodUrl: String,
@ConfigName("automod.apiKey") val automodApiKey: Secret,
@ConfigName("automod.model") val automodModel: String
)
@Module
final class Env(
db: lila.db.Db,
@@ -20,26 +31,26 @@ final class Env(
cacheApi: lila.memo.CacheApi,
langList: lila.core.i18n.LangList,
net: NetConfig,
appConfig: play.api.Configuration,
appConfig: Configuration,
settingStore: lila.memo.SettingStore.Builder,
ws: play.api.libs.ws.StandaloneWSClient
)(using Executor, Scheduler, akka.stream.Materializer, play.api.Mode):
ws: play.api.libs.ws.StandaloneWSClient,
client: lila.search.client.SearchClient
)(using Executor, Scheduler, play.api.Mode):
export net.{ assetBaseUrl, baseUrl, domain, assetDomain }
private val colls = new UblogColls(db(CollName("ublog_blog")), db(CollName("ublog_post")))
private val config = appConfig.get[UblogConfig]("ublog")(using AutoConfig.loader)
private val colls = new UblogColls(db(CollName("ublog_blog")), db(CollName("ublog_post")))
val topic = wire[UblogTopicApi]
val rank: UblogRank = wire[UblogRank]
val automod = wire[UblogAutomod]
val api: UblogApi = wire[UblogApi]
val paginator = wire[UblogPaginator]
val search: UblogSearch = wire[UblogSearch]
val bestOf = wire[UblogBestOf]
val paginator = wire[UblogPaginator]
val markup = wire[UblogMarkup]
@@ -50,25 +61,11 @@ final class Env(
val lastPostsCache: AsyncLoadingCache[Unit, List[UblogPost.PreviewPost]] =
cacheApi.unit[List[UblogPost.PreviewPost]]:
_.refreshAfterWrite(10.seconds).buildAsyncFuture: _ =>
import scalalib.ThreadLocalRandom
val lookInto = 15
val keep = 9
api
.pinnedPosts(2)
.zip:
api
.latestPosts(lookInto)
.map:
_.groupBy(_.blog)
.flatMap(_._2.headOption)
.map(ThreadLocalRandom.shuffle)
.map(_.take(keep).toList)
.map(_ ++ _)
api.carousel().map(_.shuffled)
Bus.sub[lila.core.mod.Shadowban]:
case lila.core.mod.Shadowban(userId, v) =>
api.setShadowban(userId, v) >>
rank.recomputeRankOfAllPostsOfBlog(UblogBlog.Id.User(userId))
api.setShadowban(userId, v)
import lila.core.security.ReopenAccount
Bus.sub[ReopenAccount]:
+190 -95
View File
@@ -2,26 +2,31 @@ package lila.ublog
import reactivemongo.akkastream.{ AkkaStreamCursor, cursorProducer }
import reactivemongo.api.*
import reactivemongo.api.bson.BSONDocument
import lila.core.shutup.{ PublicSource, ShutupApi }
import lila.core.timeline as tl
import lila.core.LightUser
import lila.db.dsl.{ *, given }
import lila.memo.PicfitApi
import lila.core.user.KidMode
import lila.core.ublog.BlogsBy
import lila.core.timeline.{ Propagate, UblogPostLike }
final class UblogApi(
colls: UblogColls,
userRepo: lila.core.user.UserRepo,
rank: UblogRank,
userApi: lila.core.user.UserApi,
picfitApi: PicfitApi,
shutupApi: ShutupApi,
irc: lila.core.irc.IrcApi,
automod: UblogAutomod
automod: UblogAutomod,
config: UblogConfig
)(using Executor)(using scheduler: Scheduler)
extends lila.core.ublog.UblogApi:
import UblogBsonHandlers.{ *, given }
import UblogBlog.Tier
def create(data: UblogForm.UblogPostData, author: User): Fu[UblogPost] =
val post = data.create(author)
@@ -34,25 +39,22 @@ final class UblogApi(
def update(data: UblogForm.UblogPostData, prev: UblogPost)(using me: Me): Fu[UblogPost] = for
author <- userApi.byId(prev.created.by).map(_ | me.value)
blog <- getUserBlog(author, insertMissing = true)
post = data.update(me.value, prev)
_ = triggerAutomod(post)
post = data.update(me.value, prev)
isFirstPublish = prev.lived.isEmpty && post.live
_ <- colls.post.update.one($id(prev.id), $set(bsonWriteObjTry[UblogPost](post).get))
_ <- (post.live && prev.lived.isEmpty).so(onFirstPublish(author, blog, post))
_ = if isFirstPublish then onFirstPublish(author.light, blog, post)
_ = triggerAutomod(post): res =>
if isFirstPublish && blog.tier > Tier.HIDDEN
then sendPostToZulip(author.light, post, blog.modTier.getOrElse(blog.tier), res)
yield post
private def onFirstPublish(author: User, blog: UblogBlog, post: UblogPost): Funit =
for _ <- rank
.computeRank(blog, post)
.so: rank =>
colls.post.updateField($id(post.id), "rank", rank).void
yield
lila.common.Bus.pub(UblogPost.Create(post))
if blog.visible then
lila.common.Bus.pub:
tl.Propagate(tl.UblogPost(author.id, post.id, post.slug, post.title))
.toFollowersOf(post.created.by)
shutupApi.publicText(author.id, post.allText, PublicSource.Ublog(post.id))
sendPostToZulip(author, post, blog.modTier)
private def onFirstPublish(author: LightUser, blog: UblogBlog, post: UblogPost) =
lila.common.Bus.pub(UblogPost.Create(post))
if blog.visible then
lila.common.Bus.pub:
tl.Propagate(tl.UblogPost(author.id, post.id, post.slug, post.title))
.toFollowersOf(post.created.by)
shutupApi.publicText(author.id, post.allText, PublicSource.Ublog(post.id))
def getUserBlogOption(user: User): Fu[Option[UblogBlog]] =
getBlog(UblogBlog.Id.User(user.id))
@@ -60,12 +62,9 @@ final class UblogApi(
def getUserBlog(user: User, insertMissing: Boolean = false): Fu[UblogBlog] =
getUserBlogOption(user).getOrElse:
if insertMissing then
for
user <- userApi.withPerfs(user)
blog = UblogBlog.make(user)
_ <- colls.blog.insert.one(blog).void
yield blog
else fuccess(UblogBlog.makeWithoutPerfs(user))
val blog = UblogBlog.make(user)
for _ <- colls.blog.insert.one(blog).void yield blog
else fuccess(UblogBlog.make(user))
def getBlog(id: UblogBlog.Id): Fu[Option[UblogBlog]] = colls.blog.byId[UblogBlog](id.full)
@@ -77,9 +76,6 @@ final class UblogApi(
.dmap:
_.filter(_.allows.edit)
def findByIdAndBlog(id: UblogPostId, blog: UblogBlog.Id): Fu[Option[UblogPost]] =
colls.post.one[UblogPost]($id(id) ++ $doc("blog" -> blog), postProjection)
def latestPosts(blogId: UblogBlog.Id, nb: Int): Fu[List[UblogPost.PreviewPost]] =
colls.post
.find($doc("blog" -> blogId, "live" -> true), previewPostProjection.some)
@@ -91,8 +87,8 @@ final class UblogApi(
val blogId = UblogBlog.Id.User(user.id)
val canView = fuccess(me.exists(_.is(user))) >>|
colls.blog
.primitiveOne[UblogRank.Tier]($id(blogId.full), "tier")
.dmap(_.exists(_ >= UblogRank.Tier.UNLISTED))
.primitiveOne[Tier]($id(blogId.full), "tier")
.dmap(_.exists(_ > Tier.HIDDEN))
canView.flatMapz { blogPreview(blogId, nb).dmap(some) }
def blogPreview(blogId: UblogBlog.Id, nb: Int): Fu[UblogPost.BlogPreview] =
@@ -101,84 +97,89 @@ final class UblogApi(
.zip(latestPosts(blogId, nb))
.map((UblogPost.BlogPreview.apply).tupled)
def pinnedPosts(nb: Int): Fu[List[UblogPost.PreviewPost]] =
colls.post
.find($doc("live" -> true, "pinned" -> true), previewPostProjection.some)
.sort($doc("rank" -> -1))
.cursor[UblogPost.PreviewPost](ReadPref.sec)
.list(nb)
def carousel(): Fu[UblogPost.CarouselPosts] =
for
pinned <- colls.post
.find($doc("live" -> true, "featured.until" -> $gte(nowInstant)), previewPostProjection.some)
.sort($doc("featured.until" -> -1))
.cursor[UblogPost.PreviewPost](ReadPref.sec)
.list(config.carouselSize)
def latestPosts(nb: Int): Fu[List[UblogPost.PreviewPost]] =
colls.post
.find(
$doc("live" -> true, "pinned".$ne(true), "topics".$ne(UblogTopic.offTopic)),
previewPostProjection.some
)
.sort($doc("rank" -> -1))
.cursor[UblogPost.PreviewPost](ReadPref.sec)
.list(nb)
queue <- colls.post
.find(
$doc("live" -> true, "featured.at" -> $gte(nowInstant.minusMonths(1))),
previewPostProjection.some
)
.sort($doc("featured.at" -> -1))
.cursor[UblogPost.PreviewPost](ReadPref.sec)
.list(config.carouselSize - pinned.size)
yield UblogPost.CarouselPosts(pinned, queue)
def postPreview(id: UblogPostId) =
colls.post.byId[UblogPost.PreviewPost](id, previewPostProjection)
private def postPreviews(ids: List[UblogPostId]) = ids.nonEmpty.so:
colls.post.byIdsProj[UblogPost.PreviewPost, UblogPostId](ids, previewPostProjection, _.sec)
def searchResultPreviews(ids: Seq[UblogPostId]): Future[Seq[UblogPost.PreviewPost]] = ids.nonEmpty.so:
colls.post
.find($inIds(ids) ++ $doc("live" -> true), previewPostProjection.some)
.cursor[UblogPost.PreviewPost](ReadPref.sec)
.list(config.searchPageSize.value)
.map: results =>
ids.collect(results.iterator.map(p => p.id -> p).toMap) // lila-search order
def recommend(blog: UblogBlog.Id, post: UblogPost)(using kid: KidMode): Fu[List[UblogPost.PreviewPost]] =
for
sameAuthor <- colls.post
.find($doc("blog" -> blog, "live" -> true, "_id".$ne(post.id)), previewPostProjection.some)
.find(
$doc("blog" -> blog, "live" -> true, "_id".$ne(post.id), "automod.evergreen".$ne(false)),
previewPostProjection.some
)
.sort($doc("lived.at" -> -1))
.cursor[UblogPost.PreviewPost](ReadPref.sec)
.list(3)
similarIds = post.similar.so(_.filterNot(s => s.count < 4 || sameAuthor.exists(_.id == s.id)).map(_.id))
similar <- postPreviews(similarIds)
similar <- colls.post
.find(
$inIds(similarIds) ++ $doc("live" -> true, "automod.evergreen".$ne(false)),
previewPostProjection.some
)
.cursor[UblogPost.PreviewPost](ReadPref.sec)
.list(9)
mix = (similar ++ sameAuthor).filter(_.isLichess || kid.no)
yield scala.util.Random.shuffle(mix).take(6)
object image:
private def rel(post: UblogPost) = s"ublog:${post.id}"
def upload(user: User, post: UblogPost, picture: PicfitApi.FilePart): Fu[UblogPost] = for
pic <- picfitApi.uploadFile(rel(post), picture, userId = user.id)
image = post.image.fold(UblogImage(pic.id))(_.copy(id = pic.id))
_ <- colls.post.updateField($id(post.id), "image", image)
yield post.copy(image = image.some)
def deleteAll(post: UblogPost): Funit = for
_ <- deleteImage(post)
_ <- picfitApi.deleteByIdsAndUser(PicfitApi.findInMarkdown(post.markdown).toSeq, post.created.by)
yield ()
def delete(post: UblogPost): Fu[UblogPost] = for
_ <- deleteImage(post)
_ <- colls.post.unsetField($id(post.id), "image")
yield post.copy(image = none)
def deleteImage(post: UblogPost): Funit = picfitApi.deleteByRel(rel(post))
private def sendPostToZulip(user: User, post: UblogPost, modTier: Option[UblogRank.Tier]): Funit =
val tierName = modTier.fold("non-tiered")(t => s"${UblogRank.Tier.name(t).toLowerCase} tier")
private def sendPostToZulip(
user: LightUser,
post: UblogPost,
tier: Tier,
mod: Option[UblogAutomod.Assessment]
): Funit =
val automodNotes = mod.map: r =>
~r.flagged.map("Flagged: " + _ + "\n") +
~r.commercial.map("Commercial: " + _ + "\n")
irc.ublogPost(
user.light,
user,
id = post.id,
slug = post.slug,
title = post.title,
intro = post.intro,
topic = s"$tierName new posts"
topic = mod.fold(Tier.name(tier).toLowerCase())(_.quality.label + " quality") + " new posts",
automodNotes
)
private def triggerAutomod(post: UblogPost) =
private def triggerAutomod(post: UblogPost)(andThen: (mod: Option[UblogAutomod.Assessment]) => Unit) =
val retries = 5 // 30s, 1m, 2m, 4m, 8m
if post.live then attempt()
def attempt(n: Int = 0): Unit =
automod(post)
.flatMapz: res =>
colls.post.update.one($id(post.id), $set("automod" -> res)).void
.flatMapz: mod =>
andThen(mod.some)
colls.post.update.one($id(post.id), $set("automod" -> mod)).void
.recover: e =>
if n < retries then scheduler.scheduleOnce((30 * math.pow(2, n).toInt).seconds)(attempt(n + 1))
else logger.warn(s"automod ${post.id} failed after $retries retry attempts", e)
else
andThen(none)
logger.warn(s"automod ${post.id} failed after $retries retry attempts", e)
def liveLightsByIds(ids: List[UblogPostId]): Fu[List[UblogPost.LightPost]] =
colls.post
@@ -191,24 +192,18 @@ final class UblogApi(
_ <- image.deleteAll(post)
yield ()
def setModTier(blog: UblogBlog.Id, tier: UblogRank.Tier): Funit =
def setModTier(blog: UblogBlog.Id, tier: Tier): Funit =
colls.blog.update
.one($id(blog), $set("modTier" -> tier, "tier" -> tier), upsert = true)
.void
def setTierIfBlogExists(blog: UblogBlog.Id, tier: UblogRank.Tier): Funit =
def setTierIfBlogExists(blog: UblogBlog.Id, tier: Tier): Funit =
colls.blog.update.one($id(blog), $set("tier" -> tier)).void
def setModAdjust(id: UblogPostId, adjust: Int, pinned: Boolean, assess: Option[String]): Funit =
val update = $set:
$doc("rankAdjustDays" -> adjust, "pinned" -> pinned) ++
assess.so(a => $doc("automod.classification" -> a))
colls.post.update.one($id(id), update).void
def onAccountClose(user: User) = setTierIfBlogExists(UblogBlog.Id.User(user.id), UblogRank.Tier.HIDDEN)
def onAccountClose(user: User) = setTierIfBlogExists(UblogBlog.Id.User(user.id), Tier.HIDDEN)
def onAccountReopen(user: User) = getUserBlogOption(user).flatMapz: blog =>
setTierIfBlogExists(UblogBlog.Id.User(user.id), blog.modTier | UblogRank.Tier.defaultWithoutPerfs(user))
setTierIfBlogExists(UblogBlog.Id.User(user.id), blog.modTier | Tier.default(user))
def onAccountDelete(user: User) = for
_ <- colls.blog.delete.one($id(UblogBlog.Id.User(user.id)))
@@ -218,9 +213,87 @@ final class UblogApi(
def postCursor(user: User): AkkaStreamCursor[UblogPost] =
colls.post.find($doc("blog" -> s"user:${user.id}")).cursor[UblogPost](ReadPref.sec)
def liked(post: UblogPost)(user: User): Fu[Boolean] =
colls.post.exists($id(post.id) ++ $doc("likers" -> user.id))
def like(postId: UblogPostId, v: Boolean)(using me: Me): Fu[UblogPost.Likes] =
colls.post.update
.one(
$id(postId),
if v then $addToSet("likers" -> me.userId) else $pull("likers" -> me.userId)
)
.flatMap: res =>
colls.post
.aggregateOne(): framework =>
import framework.*
Match($id(postId)) -> List(
PipelineOperator(
$lookup.simple(from = colls.blog, as = "blog", local = "blog", foreign = "_id")
),
UnwindField("blog"),
Project($doc("tier" -> "$blog.tier", "likes" -> $doc("$size" -> "$likers", "title" -> true)))
)
.map: docOption =>
for
doc <- docOption
id <- doc.getAsOpt[UblogPostId]("_id")
likes <- doc.getAsOpt[Int]("likes")
tier <- doc
.getAsOpt[Int]("tier")
.map(t => if t == 0 then Tier.HIDDEN else Tier.NORMAL)
title <- doc.string("title")
yield (id, likes, tier, title)
.flatMap:
case None => fuccess(UblogPost.Likes(0))
case Some(id, likes, tier, title) =>
for
_ <- colls.post.update.one($id(postId), $set("likes" -> likes))
_ =
if res.nModified > 0 && v && tier > Tier.HIDDEN
then lila.common.Bus.pub(Propagate(UblogPostLike(me, id, title)).toFollowersOf(me))
yield UblogPost.Likes(likes)
def setModAdjust(post: UblogPost, d: UblogForm.ModPostData): Fu[Option[UblogAutomod.Assessment]] =
import UblogAutomod.{ Quality, Assessment }
def maybeCopy(v: Option[String], base: Option[String]) =
v match
case Some("") => none // form sends empty string to unset
case None => base
case _ => v
if !d.hasUpdates then fuccess(post.automod)
else
val base = post.automod.getOrElse(Assessment(quality = Quality.Good))
val assessment = Assessment(
quality = d.quality.flatMap(Quality.fromName).getOrElse(base.quality),
evergreen = d.evergreen.orElse(base.evergreen),
flagged = maybeCopy(d.flagged, base.flagged),
commercial = maybeCopy(d.commercial, base.commercial)
)
colls.post.update
.one($id(post.id), $set("automod" -> assessment))
.inject(assessment.some)
def setFeatured(post: UblogPost, data: UblogForm.ModPostData)(using
me: Me
): Fu[Option[UblogPost.Featured]] =
if data.featured.isEmpty && data.featuredUntil.isEmpty then fuccess(post.featured)
else
val featured =
data.featured.collect:
case true =>
UblogPost
.Featured(
me.userId,
data.featuredUntil.fold(nowInstant.some)(_ => none),
data.featuredUntil.map(nowInstant.plusDays(_))
)
val update = featured.fold($unset("featured"))(f => $set("featured" -> f))
colls.post.update.one($id(post.id), update).inject(featured)
private[ublog] def setShadowban(userId: UserId, v: Boolean) = {
if v then fuccess(UblogRank.Tier.HIDDEN)
else userApi.withPerfs(userId).map(_.fold(UblogRank.Tier.HIDDEN)(UblogRank.Tier.default))
if v then fuccess(Tier.HIDDEN)
else userApi.byId(userId).map(_.fold(Tier.HIDDEN)(Tier.default))
}.flatMap:
setModTier(UblogBlog.Id.User(userId), _)
@@ -229,19 +302,20 @@ final class UblogApi(
(u.count.game > 0 && u.createdSinceDays(2)) || u.hasTitle || u.isVerified || u.isPatron
}
// So far this only hits a prod index if $select contains `topics`, or if byDate is false
// i.e. byDate can only be true if $select contains `topics`
private[ublog] def aggregateVisiblePosts(
select: Bdoc,
offset: Int,
length: Int,
ranking: UblogRank.Sorting = UblogRank.Sorting.ByRank
sort: BlogsBy = BlogsBy.Newest
) =
colls.post
.aggregateList(length, _.sec): framework =>
import framework.*
Match(select ++ $doc("live" -> true)) -> (ranking.sortingQuery(colls.post, framework) ++
List(Limit(100))
val aggSort = sort match
case BlogsBy.Oldest => Sort(Ascending("lived.at"))
case BlogsBy.Likes => Sort(Descending("likes"))
case _ => Sort(Descending("lived.at"))
Match(select ++ $doc("live" -> true)) -> (List(aggSort)
++ removeUnlistedOrClosedAndProjectForPreview(colls.post, framework) ++ List(
Skip(offset),
Limit(length)
@@ -265,7 +339,7 @@ final class UblogApi(
local = "blog",
foreign = "_id",
pipe = List(
$doc("$match" -> $expr($doc("$gte" -> $arr("$tier", UblogRank.Tier.LOW)))),
$doc("$match" -> $expr($doc("$gt" -> $arr("$tier", Tier.HIDDEN)))),
$doc("$project" -> $id(true))
)
)
@@ -286,3 +360,24 @@ final class UblogApi(
UnwindField("user"),
Project(previewPostProjection ++ $doc("blog" -> "$blog._id"))
)
object image:
private def rel(post: UblogPost) = s"ublog:${post.id}"
def upload(user: User, post: UblogPost, picture: PicfitApi.FilePart): Fu[UblogPost] = for
pic <- picfitApi.uploadFile(rel(post), picture, userId = user.id)
image = post.image.fold(UblogImage(pic.id))(_.copy(id = pic.id))
_ <- colls.post.updateField($id(post.id), "image", image)
yield post.copy(image = image.some)
def deleteAll(post: UblogPost): Funit = for
_ <- deleteImage(post)
_ <- picfitApi.deleteByIdsAndUser(PicfitApi.findInMarkdown(post.markdown).toSeq, post.created.by)
yield ()
def delete(post: UblogPost): Fu[UblogPost] = for
_ <- deleteImage(post)
_ <- colls.post.unsetField($id(post.id), "image")
yield post.copy(image = none)
def deleteImage(post: UblogPost): Funit = picfitApi.deleteByRel(rel(post))
+54 -57
View File
@@ -1,7 +1,5 @@
package lila.ublog
import com.typesafe.config.Config
import play.api.{ ConfigLoader, Configuration }
import play.api.libs.json.*
import play.api.libs.ws.*
import play.api.libs.ws.JsonBodyWritables.*
@@ -11,40 +9,43 @@ import com.roundeights.hasher.Algo
import lila.memo.SettingStore
import lila.core.data.Text
import lila.memo.SettingStore.Text.given
import lila.core.config.Secret
// see also:
// file://./../../../../bin/ublog-automod.mjs
// file://./../../../../../sysadmin/prompts/ublog-system-prompt.txt
private object UblogAutomod:
object UblogAutomod:
case class Result(
classification: String,
flagged: Option[String],
commercial: Option[String],
offtopic: Option[String],
evergreen: Option[Boolean],
hash: Option[String] = none
private val schemaVersion = 1
case class Assessment(
quality: Quality,
flagged: Option[String] = none,
commercial: Option[String] = none,
evergreen: Option[Boolean] = none,
hash: Option[String] = none,
version: Int = schemaVersion
)
private case class Config(apiKey: Secret, model: String, url: String)
private case class FuzzyResult(
quality: Option[String],
classification: Option[String],
quality: String,
flagged: Option[JsValue],
commercial: Option[JsValue],
offtopic: Option[JsValue],
evergreen: Option[Boolean]
)
private given Reads[FuzzyResult] = Json.reads[FuzzyResult]
private[ublog] val classifications = List("spam", "weak", "good", "great")
enum Quality:
case Spam, Weak, Good, Great
def label: String = toString.toLowerCase
object Quality:
def fromName(name: String): Option[Quality] =
values.find(_.toString.equalsIgnoreCase(name))
final class UblogAutomod(
ws: StandaloneWSClient,
appConfig: Configuration,
config: UblogConfig,
settingStore: lila.memo.SettingStore.Builder
)(using Executor):
@@ -56,35 +57,32 @@ final class UblogAutomod(
default = Text("")
)
private val cfg: UblogAutomod.Config =
import lila.common.config.given
import lila.common.autoconfig.AutoConfig
appConfig.get[UblogAutomod.Config]("ublog.automod")(using AutoConfig.loader)
val temperatureSetting = settingStore[Float](
"ublogAutomodTemperature",
text = "Ublog automod temperature".some,
default = 0.3
)
private val dedup = scalalib.cache.OnceEvery.hashCode[String](1.hour)
private[ublog] def apply(post: UblogPost): Fu[Option[Result]] = post.live.so:
val text = post.allText.take(40_000) // roughly 10k tokens
dedup(s"${post.id}:$text").so(fetchText(text))
private[ublog] def apply(post: UblogPost): Fu[Option[Assessment]] = post.live.so:
val text = post.allText.take(40_000) // bin/ublog-automod.mjs, important for hash
dedup(s"${post.id}:$text").so(assess(text))
private def fetchText(userText: String): Fu[Option[Result]] =
private def assess(userText: String): Fu[Option[Assessment]] =
val prompt = promptSetting.get().value
(cfg.apiKey.value.nonEmpty && prompt.nonEmpty).so:
(config.automodApiKey.value.nonEmpty && prompt.nonEmpty).so:
val body = Json.obj(
"model" -> cfg.model,
// "response_format" -> "json", // not universally supported it seems
// "temperature" -> 0.7,
// "top_p" -> 1,
// "frequency_penalty" -> 0,
// "presence_penalty" -> 0
"messages" -> Json.arr(
"model" -> config.automodModel,
"temperature" -> temperatureSetting.get(),
"messages" -> Json.arr(
Json.obj("role" -> "system", "content" -> prompt),
Json.obj("role" -> "user", "content" -> userText)
)
)
ws.url(cfg.url)
ws.url(config.automodUrl)
.withHttpHeaders(
"Authorization" -> s"Bearer ${cfg.apiKey.value}",
"Authorization" -> s"Bearer ${config.automodApiKey.value}",
"Content-Type" -> "application/json"
)
.post(body)
@@ -98,32 +96,31 @@ final class UblogAutomod(
yield result) match
case None => fufail(s"${rsp.status} ${rsp.body.take(500)}")
case Some(res) =>
lila.mon.ublog.automod.classification(res.classification).increment()
lila.mon.ublog.automod.quality(res.quality.label).increment()
lila.mon.ublog.automod.flagged(res.flagged.isDefined).increment()
val hash = Algo.sha256(userText).hex.take(12) // matches ublog-automod.mjs hash
fuccess(res.copy(hash = hash.some).some)
.monSuccess(_.ublog.automod.request)
private def normalize(msg: String): Option[Result] = // keep in sync with bin/ublog-automod.mjs
val trimmed = msg.slice(msg.lastIndexOf('{'), msg.lastIndexOf('}') + 1)
Json.parse(trimmed).asOpt[FuzzyResult].flatMap { res =>
val fixed = Result(
classification = res.classification.orElse(res.quality).getOrElse("good"),
evergreen = res.evergreen,
flagged = fix(res.flagged),
commercial = fix(res.commercial),
offtopic = fix(res.offtopic)
)
fixed.classification match
case "great" | "good" => fixed.some
case "weak" => fixed.copy(evergreen = none).some
case "spam" => fixed.copy(evergreen = none, offtopic = none, commercial = none).some
case _ => none
}
private def normalize(msg: String): Option[Assessment] = // keep in sync with bin/ublog-automod.mjs
val trimmed = msg.slice(msg.indexOf('{', msg.indexOf("</think>")), msg.lastIndexOf('}') + 1)
Json
.parse(trimmed)
.asOpt[FuzzyResult]
.flatMap: res =>
Quality.values
.find(_.toString.equalsIgnoreCase(res.quality))
.map: q =>
import Quality.*
Assessment(
quality = q,
evergreen = if q == Good || q == Great then res.evergreen else none,
flagged = fixString(res.flagged),
commercial = if q != Spam then fixString(res.commercial) else none
)
private def fix(field: Option[JsValue]): Option[String] = // LLM make poopy
val bad = Set("none", "reason", "false", "")
private def fixString(field: Option[JsValue]): Option[String] = // LLM make poopy
val isBad = (v: String) => Set("none", "false", "").exists(_.equalsIgnoreCase(v))
field match
case Some(JsString(value)) => value.trim().toLowerCase().some.filterNot(bad)
case Some(JsBoolean(true)) => "true".some
case Some(JsString(value)) => value.trim().some.filterNot(isBad)
case _ => none
-102
View File
@@ -1,102 +0,0 @@
package lila.ublog
import java.time.temporal.ChronoUnit
import java.time.{ Year, YearMonth, ZoneOffset, LocalTime }
import scala.util.Try
import scalalib.paginator.{ AdapterLike, Paginator }
import lila.db.dsl.{ *, given }
import lila.memo.CacheApi
object UblogBestOf:
private val ublogOrigin = YearMonth.of(2021, 9)
private def nbMonthsBackward = ublogOrigin.until(currentYearMonth, ChronoUnit.MONTHS).toInt
private def currentYearMonth = YearMonth.now(ZoneOffset.UTC)
def allYears = (ublogOrigin.getYear to currentYearMonth.getYear).toList
def selector(month: YearMonth) =
val (start, until) = boundsOfMonth(month)
// to hit topic prod index
$doc("topics".$ne(UblogTopic.offTopic), "lived.at".$gt(start).$lt(until))
private def boundsOfMonth(month: YearMonth): (Instant, Instant) =
val start = month.atDay(1).atStartOfDay()
val until = month.atEndOfMonth().atTime(LocalTime.MAX)
(start.toInstant(ZoneOffset.UTC), until.toInstant(ZoneOffset.UTC))
def readYear(year: Int): Option[Year] =
(ublogOrigin.getYear <= year && year <= currentYearMonth.getYear).so(Try(Year.of(year)).toOption)
def isValid(ym: YearMonth): Boolean =
// writing it as negative allow bounds to be included
!(ym.isBefore(ublogOrigin) || ym.isAfter(currentYearMonth))
def readYearMonth(year: Int, month: Int): Option[YearMonth] =
Try(YearMonth.of(year, month)).toOption
private def monthsBack(n: Int): YearMonth =
currentYearMonth.minusMonths(n)
// from `now` go back to `offset` months and from that point gives all `length` precedecing months
private def slice(offset: Int, length: Int): Seq[YearMonth] =
val from = currentYearMonth.minusMonths(offset)
(0 until length).map(x => from.minusMonths(x.toInt))
case class WithPosts(yearMonth: YearMonth, posts: List[UblogPost.PreviewPost])
final class UblogBestOf(colls: UblogColls, ublogApi: UblogApi, cacheApi: CacheApi)(using Executor):
import UblogBsonHandlers.given
private val cache = cacheApi[(Int, Int), List[UblogBestOf.WithPosts]](16, "ublog.bestOf"):
_.expireAfterWrite(1.hour).buildAsyncFuture(runMonstrousAggregation)
private def runMonstrousAggregation(offset: Int, length: Int): Fu[List[UblogBestOf.WithPosts]] =
colls.post
.aggregateList(length, _.sec): framework =>
import framework.*
Facet(
UblogBestOf
.slice(offset = offset, length = length)
.zipWithIndex
.map: (month, i) =>
s"$i" -> (List(
Match($doc("live" -> true) ++ UblogBestOf.selector(month))
) ++ UblogRank.Sorting.ByTimelessRank.sortingQuery(colls.post, framework) ++ List(
Limit(4)
) ++ ublogApi.removeUnlistedOrClosedAndProjectForPreview(colls.post, framework))
) -> List(
Project($doc("all" -> $doc("$objectToArray" -> "$$ROOT"))),
UnwindField("all"),
ReplaceRootField("all"),
Project(
$doc(
"v" -> true,
"monthsBack" -> $doc("$toInt" -> "$k")
)
),
Sort(Ascending("monthsBack"))
)
.map: docs =>
for
doc <- docs
monthsBack <- doc.int("monthsBack")
yearMonth = UblogBestOf.monthsBack(offset + monthsBack)
posts <- doc.getAsOpt[List[UblogPost.PreviewPost]]("v")
yield UblogBestOf.WithPosts(yearMonth, posts)
private val maxPerPage = MaxPerPage(12) // a year
def liveByYear(page: Int): Fu[Paginator[UblogBestOf.WithPosts]] =
Paginator(
adapter = new AdapterLike[UblogBestOf.WithPosts]:
def nbResults: Fu[Int] = fuccess(UblogBestOf.nbMonthsBackward)
def slice(offset: Int, length: Int) = cache.get(offset -> length)
,
currentPage = page,
maxPerPage = maxPerPage
)
+31 -13
View File
@@ -1,16 +1,15 @@
package lila.ublog
import lila.core.perf.UserWithPerfs
import lila.core.perm.Granter
case class UblogBlog(
_id: UblogBlog.Id,
tier: UblogRank.Tier, // actual tier, auto or set by a mod
modTier: Option[UblogRank.Tier] // tier set by a mod
tier: UblogBlog.Tier, // actual tier, auto or set by a mod
modTier: Option[UblogBlog.Tier] // tier set by a mod
):
inline def id = _id
def visible = tier >= UblogRank.Tier.UNLISTED
def listed = tier >= UblogRank.Tier.LOW
def visible = tier > UblogBlog.Tier.HIDDEN
def listed = tier > UblogBlog.Tier.HIDDEN
def userId = id match
case UblogBlog.Id.User(userId) => userId
@@ -18,6 +17,31 @@ case class UblogBlog(
def allows = UblogBlog.Allows(userId)
object UblogBlog:
opaque type Tier = Int
object Tier extends RelaxedOpaqueInt[Tier]:
val HIDDEN: Tier = 0
val UNLISTED: Tier = 1
val LOW: Tier = 2
val NORMAL: Tier = 3
val HIGH: Tier = 4
val BEST: Tier = 5
def default(user: User) =
if user.marks.troll then Tier.HIDDEN
else Tier.NORMAL
val options = List(
HIDDEN -> "Hidden",
UNLISTED -> "Unlisted",
LOW -> "Low",
NORMAL -> "Normal",
HIGH -> "High",
BEST -> "Best"
)
def name(tier: Tier) = options.collectFirst {
case (t, n) if t == tier => n
} | "???"
enum Id(val full: String):
case User(id: UserId) extends Id(s"user${Id.sep}$id")
@@ -27,15 +51,9 @@ object UblogBlog:
case Array("user", id) => User(UserId(id)).some
case _ => none
def make(user: UserWithPerfs) = UblogBlog(
def make(user: User) = UblogBlog(
_id = Id.User(user.id),
tier = UblogRank.Tier.default(user),
modTier = none
)
def makeWithoutPerfs(user: User) = UblogBlog(
_id = Id.User(user.id),
tier = UblogRank.Tier.defaultWithoutPerfs(user),
tier = UblogBlog.Tier.default(user),
modTier = none
)
+18 -11
View File
@@ -1,6 +1,6 @@
package lila.ublog
import scala.util.{ Try, Success }
import scala.util.Success
import play.api.i18n.Lang
import reactivemongo.api.bson.*
@@ -8,8 +8,8 @@ import lila.db.dsl.{ *, given }
private object UblogBsonHandlers:
import UblogPost.{ LightPost, PreviewPost, Recorded }
import UblogAutomod.Result
import UblogPost.{ LightPost, PreviewPost, Recorded, Featured }
import UblogAutomod.{ Quality, Assessment }
given BSONHandler[UblogBlog.Id] = tryHandler(
{ case BSONString(v) => UblogBlog.Id(v).toTry(s"Invalid blog id $v") },
@@ -47,24 +47,31 @@ private object UblogBsonHandlers:
given BSONHandler[Lang] = langByCodeHandler
given BSONDocumentHandler[Recorded] = Macros.handler
given BSONDocumentHandler[Featured] = Macros.handler
given BSONDocumentHandler[UblogImage] = Macros.handler
given BSONDocumentHandler[UblogPost] = Macros.handler
given BSONDocumentHandler[LightPost] = Macros.handler
given BSONDocumentHandler[PreviewPost] = Macros.handler
given BSONDocumentHandler[UblogSimilar] = Macros.handler
given BSONHandler[Quality] = tryHandler(
v => v.asOpt[Int].flatMap(Quality.values.lift).toTry(s"bad quality $v"),
quality => BSONInteger(quality.ordinal)
)
given BSONDocumentHandler[UblogAutomod.Assessment] = Macros.handler
val postProjection = $doc("likers" -> false)
val lightPostProjection = $doc("title" -> true)
val previewPostProjection =
$doc(
"blog" -> true,
"title" -> true,
"intro" -> true,
"image" -> true,
"created" -> true,
"lived" -> true,
"topics" -> true,
"sticky" -> true
"blog" -> true,
"title" -> true,
"intro" -> true,
"image" -> true,
"created" -> true,
"lived" -> true,
"featured" -> true,
"topics" -> true,
"sticky" -> true
)
val userLiveSort = $doc("sticky" -> -1, "lived.at" -> -1)
+34
View File
@@ -0,0 +1,34 @@
package lila.ublog
import java.time.{ Year, YearMonth, ZoneOffset, LocalTime }
import scala.util.Try
import lila.db.dsl.{ *, given }
object UblogByMonth:
private val ublogOrigin = YearMonth.of(2021, 9)
private def currentYearMonth = YearMonth.now(ZoneOffset.UTC)
def allYears = (ublogOrigin.getYear to currentYearMonth.getYear).toList
def selector(month: YearMonth) =
val (start, until) = boundsOfMonth(month)
// to hit topic prod index
$doc("topics".$ne(UblogTopic.offTopic), "lived.at".$gt(start).$lt(until))
private def boundsOfMonth(month: YearMonth): (Instant, Instant) =
val start = month.atDay(1).atStartOfDay()
val until = month.atEndOfMonth().atTime(LocalTime.MAX)
(start.toInstant(ZoneOffset.UTC), until.toInstant(ZoneOffset.UTC))
def readYear(year: Int): Option[Year] =
(ublogOrigin.getYear <= year && year <= currentYearMonth.getYear).so(Try(Year.of(year)).toOption)
def isValid(ym: YearMonth): Boolean =
// writing it as negative allow bounds to be included
!(ym.isBefore(ublogOrigin) || ym.isAfter(currentYearMonth))
def readYearMonth(year: Int, month: Int): Option[YearMonth] =
Try(YearMonth.of(year, month)).toOption
+46 -13
View File
@@ -2,15 +2,18 @@ package lila.ublog
import play.api.data.*
import play.api.data.Forms.*
import play.api.libs.json.*
import play.api.libs.functional.syntax.toFunctionalBuilderOps
import scalalib.model.Language
import lila.common.Form.{ cleanNonEmptyText, into, stringIn, given }
import lila.common.Form.{ cleanNonEmptyText, into, given }
import lila.core.captcha.{ CaptchaApi, WithCaptcha }
import lila.core.i18n.{ LangList, toLanguage, defaultLanguage }
import UblogAutomod.Quality
final class UblogForm(val captcher: CaptchaApi, langList: LangList):
import UblogForm.*
import UblogForm.UblogPostData
private val base =
mapping(
@@ -84,11 +87,10 @@ object UblogForm:
created = UblogPost.Recorded(user.id, nowInstant),
updated = none,
lived = none,
featured = none,
likes = UblogPost.Likes(1),
views = UblogPost.Views(0),
similar = none,
rankAdjustDays = none,
pinned = none,
automod = none
)
@@ -110,17 +112,48 @@ object UblogForm:
)
private val tierMapping =
"tier" -> number(min = UblogRank.Tier.HIDDEN.value, max = UblogRank.Tier.BEST.value)
.into[UblogRank.Tier]
"tier" -> number(min = UblogBlog.Tier.HIDDEN.value, max = UblogBlog.Tier.BEST.value)
.into[UblogBlog.Tier]
val tier = Form:
single:
tierMapping
val adjust = Form:
tuple(
"pinned" -> boolean,
tierMapping,
"days" -> optional(number(min = -180, max = 180)),
"assessment" -> optional(stringIn(UblogAutomod.classifications.toSet))
)
case class ModPostData(
quality: Option[String] = none,
evergreen: Option[Boolean] = none,
flagged: Option[String] = none,
commercial: Option[String] = none,
featured: Option[Boolean] = none,
featuredUntil: Option[Int] = none
):
def hasUpdates: Boolean =
quality.isDefined || evergreen.isDefined || flagged.isDefined ||
commercial.isDefined || featured.isDefined || featuredUntil.isDefined
def text = List(
quality.so(q => s"quality = $q") ++
evergreen.so(e => s"evergreen = $e") ++
flagged.so(f => "flagged = " + (if f == "" then "none" else s"\"$f\"")) ++
commercial.so(c => "commercial = " + (if c == "" then "none" else s"\"$c\"")) ++
featured.so(f => s"featured = $f") ++
featuredUntil.so(d => s"featured days = $d")
).mkString(", ")
object ModPostData:
def reads: Reads[ModPostData] =
(
(JsPath \ "quality")
.readNullable[String]
.filter(JsonValidationError(s"bad quality"))(_.forall(Quality.fromName(_).isDefined))
.and((JsPath \ "evergreen").readNullable[Boolean])
.and((JsPath \ "flagged").readNullable[String].map(_.map(_.take(200))))
.and((JsPath \ "commercial").readNullable[String].map(_.map(_.take(200))))
.and((JsPath \ "featured").readNullable[Boolean])
.and(
(JsPath \ "featuredUntil")
.readNullable[Int]
.filter(JsonValidationError(s"bad featuredUntil"))(_.forall(d => d > 0 && d <= 31))
)
)(ModPostData.apply)
+37 -14
View File
@@ -9,6 +9,8 @@ import scalalib.paginator.{ AdapterLike, Paginator }
import scalalib.model.Language
import lila.db.dsl.{ *, given }
import lila.db.paginator.Adapter
import lila.core.ublog.BlogsBy
import UblogAutomod.Quality
final class UblogPaginator(
colls: UblogColls,
@@ -19,10 +21,10 @@ final class UblogPaginator(
import UblogBsonHandlers.{ *, given }
import UblogPost.PreviewPost
import Quality.*
import ublogApi.aggregateVisiblePosts
import UblogRank.Sorting.{ ByDate, ByRank, ByTimelessRank }
val maxPerPage = MaxPerPage(15)
val maxPerPage = MaxPerPage(24)
def byUser[U: UserIdOf](user: U, live: Boolean, page: Int): Fu[Paginator[PreviewPost]] =
byBlog(UblogBlog.Id.User(user.id), live, page)
@@ -40,12 +42,14 @@ final class UblogPaginator(
maxPerPage = maxPerPage
)
def liveByCommunity(language: Option[Language], page: Int): Fu[Paginator[PreviewPost]] =
def liveByCommunity(language: Option[Language], filter: Boolean, page: Int): Fu[Paginator[PreviewPost]] =
val q = if filter then Good else Weak
Paginator(
adapter = new AdapterLike[PreviewPost]:
val select = $doc("live" -> true, "topics".$ne(UblogTopic.offTopic)) ++ language.so: l =>
$doc("language" -> l)
def nbResults: Fu[Int] = fuccess(10 * maxPerPage.value)
val select =
$doc("live" -> true, selectQuality(q), "topics".$ne(UblogTopic.offTopic)) ++ language.so: l =>
$doc("language" -> l)
def nbResults: Fu[Int] = fuccess(50 * maxPerPage.value)
def slice(offset: Int, length: Int) = aggregateVisiblePosts(select, offset, length)
,
currentPage = page,
@@ -65,33 +69,52 @@ final class UblogPaginator(
maxPerPage = maxPerPage
)
def liveByTopic(topic: UblogTopic, page: Int, byDate: Boolean): Fu[Paginator[PreviewPost]] =
def liveByTopic(
topic: UblogTopic,
filter: Boolean,
by: BlogsBy,
page: Int
): Fu[Paginator[PreviewPost]] =
val q =
topic match
case UblogTopic.offTopic => if filter then Weak else Spam
case _ => if filter then Good else Weak
Paginator(
adapter = new AdapterLike[PreviewPost]:
def nbResults: Fu[Int] = fuccess(10 * maxPerPage.value)
def nbResults: Fu[Int] = fuccess(50 * maxPerPage.value)
def slice(offset: Int, length: Int) =
aggregateVisiblePosts($doc("topics" -> topic), offset, length, if byDate then ByDate else ByRank)
aggregateVisiblePosts($doc("topics" -> topic, selectQuality(q)), offset, length, by)
,
currentPage = page,
maxPerPage = maxPerPage
)
// All blogs ranked by `ByTimelessRank` lived during a specific month
def liveByMonth(month: YearMonth, page: Int): Fu[Paginator[PreviewPost]] =
UblogBestOf
def liveByMonth(month: YearMonth, filter: Boolean, by: BlogsBy, page: Int): Fu[Paginator[PreviewPost]] =
val q = if filter then Good else Weak
UblogByMonth
.isValid(month)
.so:
Paginator(
adapter = new AdapterLike[PreviewPost]:
def nbResults: Fu[Int] = fuccess(10 * maxPerPage.value)
def nbResults: Fu[Int] = fuccess(50 * maxPerPage.value)
def slice(offset: Int, length: Int) =
// topics included to hit prod index
aggregateVisiblePosts(UblogBestOf.selector(month), offset, length, ByTimelessRank)
aggregateVisiblePosts(
UblogByMonth.selector(month) ++ selectQuality(q),
offset,
length,
by
)
,
currentPage = page,
maxPerPage = maxPerPage
)
private def selectQuality(q: Quality) =
// maybe we should require automod.quality, but allow unassessed for now
$or($doc("automod.quality" -> $exists(false)), $doc("automod.quality" -> $gte(q.ordinal)))
object liveByFollowed:
def apply(user: User, page: Int): Fu[Paginator[PreviewPost]] =
+17 -7
View File
@@ -2,7 +2,7 @@ package lila.ublog
import reactivemongo.api.bson.Macros.Annotations.Key
import lila.core.data.OpaqueInstant
import scalalib.ThreadLocalRandom.shuffle
import scalalib.model.Language
import lila.core.id.ImageId
@@ -22,12 +22,11 @@ case class UblogPost(
created: UblogPost.Recorded,
updated: Option[UblogPost.Recorded],
lived: Option[UblogPost.Recorded],
featured: Option[UblogPost.Featured],
likes: UblogPost.Likes,
views: UblogPost.Views,
similar: Option[List[UblogSimilar]],
rankAdjustDays: Option[Int],
pinned: Option[Boolean],
automod: Option[UblogAutomod.Result]
automod: Option[UblogAutomod.Assessment]
) extends UblogPost.BasePost
with lila.core.ublog.UblogPost:
@@ -57,9 +56,6 @@ object UblogPost:
opaque type Views = Int
object Views extends RelaxedOpaqueInt[Views]
opaque type RankDate = Instant
object RankDate extends OpaqueInstant[RankDate]
trait BasePost extends lila.core.ublog.UblogPost:
val blog: UblogBlog.Id
val title: String
@@ -68,6 +64,7 @@ object UblogPost:
val created: Recorded
val updated: Option[Recorded]
val lived: Option[Recorded]
val featured: Option[Featured]
val sticky: Option[Boolean]
def slug = UblogPost.slug(title)
def isLichess = created.by.is(UserId.lichess)
@@ -81,10 +78,23 @@ object UblogPost:
created: Recorded,
updated: Option[Recorded],
lived: Option[Recorded],
featured: Option[Featured],
sticky: Option[Boolean],
topics: List[UblogTopic]
) extends BasePost
case class Featured(by: UserId, at: Option[Instant], until: Option[Instant] = none)
case class CarouselPosts(
pinned: List[UblogPost.PreviewPost],
queue: List[UblogPost.PreviewPost]
):
def shuffled: List[UblogPost.PreviewPost] =
(pinned.headOption ++ shuffle(pinned.tailOption.getOrElse(Nil) ++ queue)).toList
def has(id: UblogPostId): Boolean =
pinned.exists(_.id == id) || queue.exists(_.id == id)
case class BlogPreview(nbPosts: Int, latests: List[PreviewPost])
def randomId = UblogPostId(scalalib.ThreadLocalRandom.nextString(8))
-213
View File
@@ -1,213 +0,0 @@
package lila.ublog
import reactivemongo.akkastream.cursorProducer
import reactivemongo.api.*
import reactivemongo.api.bson.*
import scalalib.model.Language
import lila.core.perf.UserWithPerfs
import lila.core.timeline.{ Propagate, UblogPostLike }
import lila.db.dsl.{ *, given }
object UblogRank:
opaque type Tier = Int
object Tier extends RelaxedOpaqueInt[Tier]:
val HIDDEN: Tier = 0 // not visible
val UNLISTED: Tier = 1 // not listed in community page
val LOW: Tier = 2 // from here, ranking boost
val NORMAL: Tier = 3
val HIGH: Tier = 4
val BEST: Tier = 5
def default(user: UserWithPerfs) =
if user.marks.troll then Tier.HIDDEN
else if user.hasTitle
then Tier.NORMAL
else Tier.LOW
def defaultWithoutPerfs(user: User) =
if user.marks.troll then Tier.HIDDEN
else if user.hasTitle then Tier.NORMAL
else Tier.LOW
val options = List(
HIDDEN -> "Hidden",
UNLISTED -> "Unlisted",
LOW -> "Low",
NORMAL -> "Normal",
HIGH -> "High",
BEST -> "Best"
)
object tierDays:
val LOW = -4
val HIGH = 5
val BEST = 7
val map = Map(Tier.LOW -> LOW, Tier.HIGH -> HIGH, Tier.BEST -> BEST)
val verboseOptions = List(
HIDDEN -> "Hidden",
UNLISTED -> "Unlisted",
LOW -> s"Low (${tierDays.LOW} day penalty)",
NORMAL -> "Normal",
HIGH -> s"High (${tierDays.HIGH} day bonus)",
BEST -> s"Best (${tierDays.BEST} day bonus)"
)
def name(tier: Tier) = options.collectFirst {
case (t, n) if t == tier => n
} | "???"
private def computeRank(
likes: UblogPost.Likes,
liveAt: Instant,
language: Language,
tier: Tier,
hasImage: Boolean,
days: Int
) = UblogPost.RankDate:
import Tier.*
liveAt
.minusMonths(if tier < LOW || !hasImage then 3 else 0)
.plusHours:
val tierBase = 24 * tierDays.map.getOrElse(tier, 0)
val adjustBonus = 24 * days
val likesBonus = math.sqrt(likes.value * 25) + likes.value / 100
val langBonus = if language == lila.core.i18n.defaultLanguage then 0 else -24 * 10
(tierBase + likesBonus + langBonus + adjustBonus).toInt
// `byRank` by default takes into acount the date at which the post was published
enum Sorting:
case ByDate, ByRank, ByTimelessRank
def sortingQuery(coll: Coll, framework: coll.AggregationFramework.type) =
import framework.*
this match
case ByDate => List(Sort(Descending("lived.at")))
case ByRank => List(Sort(Descending("rank")))
case ByTimelessRank =>
List(
AddFields($doc("timelessRank" -> $doc("$subtract" -> $arr("$rank", "$lived.at")))),
Sort(Descending("timelessRank"))
)
final class UblogRank(colls: UblogColls)(using Executor, akka.stream.Materializer):
import UblogBsonHandlers.given, UblogRank.Tier
private def selectLiker(userId: UserId) = $doc("likers" -> userId)
def liked(post: UblogPost)(user: User): Fu[Boolean] =
colls.post.exists($id(post.id) ++ selectLiker(user.id))
def like(postId: UblogPostId, v: Boolean)(using me: Me): Fu[UblogPost.Likes] =
colls.post.update
.one(
$id(postId),
if v then $addToSet("likers" -> me.userId) else $pull("likers" -> me.userId)
)
.flatMap: res =>
colls.post
.aggregateOne(): framework =>
import framework.*
Match($id(postId)) -> List(
PipelineOperator:
$lookup.simple(from = colls.blog, as = "blog", local = "blog", foreign = "_id")
,
UnwindField("blog"),
Project(
$doc(
"tier" -> "$blog.tier",
"likes" -> $doc("$size" -> "$likers"), // do not use denormalized field
"at" -> "$lived.at",
"language" -> true,
"title" -> true,
"imageId" -> "$image.id",
"rankAdjustDays" -> true
)
)
)
.map: docOption =>
for
doc <- docOption
id <- doc.getAsOpt[UblogPostId]("_id")
likes <- doc.getAsOpt[UblogPost.Likes]("likes")
liveAt <- doc.getAsOpt[Instant]("at")
tier <- doc.getAsOpt[Tier]("tier")
language <- doc.getAsOpt[Language]("language")
title <- doc.string("title")
adjust = ~doc.int("rankAdjustDays")
hasImage = doc.contains("imageId")
yield (id, likes, liveAt, tier, language, title, hasImage, adjust)
.flatMap:
case None => fuccess(UblogPost.Likes(0))
case Some(id, likes, liveAt, tier, language, title, hasImage, adjust) =>
// Multiple updates may race to set denormalized likes and rank,
// but values should be approximately correct, match a real like
// count (though perhaps not the latest one), and any uncontended
// query will set the precisely correct value.
for
_ <- colls.post.update
.one(
$id(postId),
$set(
"likes" -> likes,
"rank" -> UblogRank.computeRank(likes, liveAt, language, tier, hasImage, adjust)
)
)
_ =
if res.nModified > 0 && v && tier >= Tier.LOW
then lila.common.Bus.pub(Propagate(UblogPostLike(me, id, title)).toFollowersOf(me))
yield likes
def recomputePostRank(post: UblogPost): Funit =
recomputeRankOfAllPostsOfBlog(post.blog, post.id.some)
def recomputeRankOfAllPostsOfBlog(blogId: UblogBlog.Id, only: Option[UblogPostId] = none): Funit =
colls.blog.byId[UblogBlog](blogId.full).flatMapz(recomputeRankOfAllPostsOfBlog(_, only))
def recomputeRankOfAllPostsOfBlog(blog: UblogBlog, only: Option[UblogPostId]): Funit =
colls.post
.find(
$doc("blog" -> blog.id) ++ only.so($id),
$doc(List("likes", "lived", "language", "rankAdjustDays", "image").map(_ -> BSONBoolean(true))).some
)
.cursor[Bdoc](ReadPref.sec)
.list(500)
.flatMap:
_.sequentiallyVoid: doc =>
~(for
id <- doc.string("_id")
likes <- doc.getAsOpt[UblogPost.Likes]("likes")
lived <- doc.getAsOpt[UblogPost.Recorded]("lived")
language <- doc.getAsOpt[Language]("language")
hasImage = doc.contains("image")
adjust = ~doc.int("rankAdjustDays")
yield colls.post
.updateField(
$id(id),
"rank",
UblogRank.computeRank(likes, lived.at, language, blog.tier, hasImage, adjust)
)
.void)
def recomputeRankOfAllPosts: Funit =
colls.blog
.find($empty)
.sort($sort.desc("tier"))
.cursor[UblogBlog](ReadPref.sec)
.documentSource()
.mapAsyncUnordered(4)(recomputeRankOfAllPostsOfBlog(_, none))
.runWith(lila.common.LilaStream.sinkCount)
.map(nb => println(s"Recomputed rank of $nb blogs"))
def computeRank(blog: UblogBlog, post: UblogPost): Option[UblogPost.RankDate] =
post.lived.map: lived =>
UblogRank.computeRank(
post.likes,
lived.at,
post.language,
blog.tier,
post.image.nonEmpty,
~post.rankAdjustDays
)
+24
View File
@@ -0,0 +1,24 @@
package lila.ublog
import lila.core.id.UblogPostId
import lila.search.*
import lila.search.client.SearchClient
import lila.search.spec.{ Query, SortBlogsBy }
import UblogAutomod.Quality
final class UblogSearch(client: SearchClient, config: UblogConfig)(using Executor)
extends SearchReadApi[UblogPostId, Query.Ublog]:
lazy val builder = PaginatorBuilder(this, config.searchPageSize)
def fetchResults(text: String, by: lila.core.ublog.BlogsBy, minQualityOpt: Option[Quality], page: Int) =
val sortBy =
SortBlogsBy.values.find(_.toString == by.toString).getOrElse(SortBlogsBy.Score)
builder(Query.Ublog(text, sortBy, minQualityOpt.map(_.ordinal), none), page)
def search(query: Query.Ublog, from: From, size: Size): Fu[List[UblogPostId]] =
client
.search(query, from, size)
.map(res => res.hitIds.map(id => UblogPostId.apply(id.value)))
def count(query: Query.Ublog) =
client.count(query).dmap(_.count)
+23 -40
View File
@@ -1,9 +1,9 @@
package lila.ublog
import reactivemongo.api.bson.BSONNull
import scalalib.ThreadLocalRandom.shuffle
import lila.db.dsl.{ *, given }
import lila.memo.CacheApi
import UblogAutomod.Quality.Good
opaque type UblogTopic = String
object UblogTopic extends OpaqueString[UblogTopic]:
@@ -44,44 +44,27 @@ final class UblogTopicApi(colls: UblogColls, cacheApi: CacheApi)(using Executor)
private val withPostsCache =
cacheApi.unit[List[UblogTopic.WithPosts]]:
_.refreshAfterWrite(30.seconds).buildAsyncFuture: _ =>
colls.post
.aggregateList(UblogTopic.all.size, _.sec): framework =>
import framework.*
Facet(
UblogTopic.all.map: topic =>
topic.value -> List(
Match($doc("live" -> true, "topics" -> topic)),
Sort(Descending("rank")),
Project(previewPostProjection ++ $doc("rank" -> true)),
Group(BSONNull)("nb" -> SumAll, "posts" -> PushField("$ROOT")),
Project:
$doc(
"_id" -> false,
"nb" -> true,
"posts" -> $doc(
"$filter" -> $doc(
"input" -> $doc("$slice" -> $arr("$posts", 4)),
"as" -> "post",
"cond" -> $doc("$gt" -> $arr("$$post.rank", nowInstant))
)
)
)
)
) -> List(
Project($doc("all" -> $doc("$objectToArray" -> "$$ROOT"))),
UnwindField("all"),
ReplaceRootField("all"),
Unwind("v"),
Project($doc("k" -> true, "nb" -> "$v.nb", "posts" -> "$v.posts"))
)
.map: docs =>
_.refreshAfterWrite(5.minutes).buildAsyncFuture: _ =>
UblogTopic.all
.map: topic =>
for
doc <- docs
t <- doc.string("k")
topic <- UblogTopic.get(t)
nb <- doc.int("nb")
posts <- doc.getAsOpt[List[UblogPost.PreviewPost]]("posts")
yield UblogTopic.WithPosts(topic, posts, nb)
count <- colls.post.countSel:
$doc("live" -> true, "topics" -> topic, "automod.quality" -> $ne(0))
posts <- colls.post
.find(
$doc(
"live" -> true,
"topics" -> topic,
"automod.quality" -> $gte(Good.ordinal),
"likes" -> $gt(50)
),
previewPostProjection.some
)
.sort($doc("lived.at" -> -1))
.cursor[UblogPost.PreviewPost](ReadPref.sec)
.list(16)
yield UblogTopic.WithPosts(topic, shuffle(posts).take(4), count)
.parallel
def withPosts: Fu[List[UblogTopic.WithPosts]] = withPostsCache.get {}
+53 -86
View File
@@ -5,10 +5,7 @@ import lila.ui.*
import ScalatagsTemplate.{ *, given }
final class UblogPostUi(helpers: Helpers, ui: UblogUi)(
ublogRank: UblogRank,
connectLinks: Frag
):
final class UblogPostUi(helpers: Helpers, ui: UblogUi)(connectLinks: Frag):
import helpers.{ *, given }
def page(
@@ -19,7 +16,8 @@ final class UblogPostUi(helpers: Helpers, ui: UblogUi)(
others: List[UblogPost.PreviewPost],
liked: Boolean,
followable: Boolean,
followed: Boolean
followed: Boolean,
isInCarousel: Boolean
)(using ctx: Context) =
Page(s"${trans.ublog.xBlog.txt(user.username)}${post.title}")
.css("bits.ublog")
@@ -39,7 +37,7 @@ final class UblogPostUi(helpers: Helpers, ui: UblogUi)(
st.title := trans.ublog.xBlog.txt(user.username)
).some
)
.flag(_.noRobots, !blog.listed || !post.indexable || blog.tier < UblogRank.Tier.HIGH)
.flag(_.noRobots, !blog.listed || !post.indexable || blog.tier < UblogBlog.Tier.HIGH)
.csp(_.withTwitter.withInlineIconFont):
main(cls := "page-menu page-small")(
ui.menu(Left(user.id)),
@@ -51,7 +49,11 @@ final class UblogPostUi(helpers: Helpers, ui: UblogUi)(
),
(ctx.is(user) || Granter.opt(_.ModerateBlog)).option(standardFlash),
h1(cls := "ublog-post__title")(post.title),
Granter.opt(_.ModerateBlog).option(modTools(blog, post)),
Granter
.opt(_.ModerateBlog)
.option:
div(id := "mod-tools-container")(modTools(post, isInCarousel))
,
div(cls := "ublog-post__meta")(
a(
cls := userClass(user.id, none, withOnline = true),
@@ -99,7 +101,7 @@ final class UblogPostUi(helpers: Helpers, ui: UblogUi)(
),
div(cls := "ublog-post__topics")(
post.topics.map: topic =>
a(href := routes.Ublog.topic(topic.url, 1))(topic.value)
a(href := routes.Ublog.topic(topic.url, true, lila.core.ublog.BlogsBy.Newest, 1))(topic.value)
),
(~post.ads).option(
div(dataIcon := Icon.InfoCircle, cls := "ublog-post__ads-disclosure text")(
@@ -181,85 +183,50 @@ final class UblogPostUi(helpers: Helpers, ui: UblogUi)(
("yes", trans.site.unfollowX, routes.Relation.unfollow, Icon.Checkmark),
("no", trans.site.followX, routes.Relation.follow, Icon.ThumbsUp)
).map: (role, text, route, icon) =>
button(
cls := s"ublog-post__follow__$role button",
dataIcon := icon,
dataRel := route(user.id)
)(
button(cls := s"ublog-post__follow__$role button", dataIcon := icon, dataRel := route(user.id))(
span(cls := "button-label")(text(user.titleUsername))
)
private def modTools(blog: UblogBlog, post: UblogPost)(using Context) =
ublogRank
.computeRank(blog, post)
.map: rank =>
postForm(cls := "ublog-post__meta", action := routes.Ublog.modAdjust(post.id))(
fieldset(cls := "ublog-post__mod-tools")(
legend(
span(
span(
label("Rank date:"),
if ~post.pinned then "pinned"
else span(cls := "ublog-post__meta__date")(semanticDate(rank.value))
),
form3.submit("Submit")(cls := "button-empty")
)
),
div(
span(
input(
tpe := "checkbox",
id := "ublog-post-pinned",
name := "pinned",
value := "true",
post.pinned.has(true).option(checked)
),
label(`for` := "ublog-post-pinned")(" Pin to top")
),
span(
"User tier:",
st.select(name := "tier", cls := "form-control")(UblogRank.Tier.verboseOptions.map:
(value, name) =>
st.option(st.value := value.toString, (blog.tier == value).option(selected))(name))
),
span(
"Post adjust:",
input(
tpe := "number",
name := "days",
min := -180,
max := 180,
value := post.rankAdjustDays.so(_.toString)
),
"days"
)
),
post.automod.map: automod =>
val current = automod.classification match
case "quality" => "good"
case "phenomenal" => "great"
case other => other // delete once ublog_post collection is fully migrated
span(cls := "automod")(
span(
"Assessment:",
st.select(name := "assessment", cls := "form-control")(
UblogAutomod.classifications.map: assessment =>
st.option(
st.value := assessment,
(assessment == current).option(selected)
)(assessment)
)
),
span(
automod.evergreen.collect:
case true => iconFlair(Flair("nature.evergreen-tree"))(title := "Evergreen content"),
automod.flagged.map: flagged =>
i(cls := "flagged", dataIcon := Icon.CautionTriangle, title := flagged),
automod.commercial.map: commercial =>
i(cls := "commercial", title := commercial)("$"),
automod.offtopic.map: offtopic =>
i(cls := "offtopic", dataIcon := Icon.Tag, title := offtopic)
)
)
)
def modTools(post: UblogPost, isInCarousel: Boolean) =
val am = post.automod
val evergreen = am.flatMap(_.evergreen).getOrElse(false)
val flagged = am.flatMap(_.flagged).getOrElse("")
val comm = am.flatMap(_.commercial).getOrElse("")
div(id := "mod-tools", data("url") := routes.Ublog.modPost(post.id).url)(
div(
span(cls := "btn-rack")(
UblogAutomod.Quality.values.map: q =>
button(
cls := s"quality-btn btn-rack__btn ${am.exists(_.quality == q).so("lit")}",
value := q.toString
)(q.toString)
),
fieldset(cls := "carousel-fields")(
legend(a(href := routes.Ublog.modShowCarousel)("Edit Carousel"), isInCarousel.option("(live)")),
if isInCarousel then button(cls := "button button-metal carousel-remove-btn")("Remove")
else
span(
button(cls := "button button-metal carousel-add-btn")("add"),
"or",
button(cls := "button button-metal carousel-pin-btn")("pin")
)
)
),
fieldset(cls := "submit-fields")(
legend("Tags", button(cls := "button button-empty none submit")("Submit")),
span(
"Evergreen",
input(tpe := "checkbox", evergreen.option(checked := "")),
"(for recommendations)"
),
span(cls := s"commercial ${comm.isEmpty.so("empty")}", title := comm)(
"Commercial",
input(id := "commercial", value := comm)
),
span(cls := s"flagged ${flagged.isEmpty.so("empty")}", title := flagged)(
"Flagged",
input(id := "flagged", value := flagged)
)
)
)
+223 -117
View File
@@ -5,6 +5,7 @@ import java.time.YearMonth
import scalalib.paginator.Paginator
import scalalib.model.Language
import lila.ui.*
import lila.core.ublog.BlogsBy
import ScalatagsTemplate.{ *, given }
@@ -82,7 +83,7 @@ final class UblogUi(helpers: Helpers, atomUi: AtomUi)(picfitUrl: lila.core.misc.
menu(Left(user.id)),
div(cls := "page-menu__content box box-pad ublog-index")(
boxTop(
h1(trans.ublog.xBlog(userLink(user))),
h1(trans.ublog.xBlog(userLink(user, withFlair = false))),
div(cls := "box__top__actions")(
blog.allows.moderate.option(tierForm(blog)),
blog.allows.draft.option(
@@ -109,6 +110,7 @@ final class UblogUi(helpers: Helpers, atomUi: AtomUi)(picfitUrl: lila.core.misc.
def community(
language: Option[Language],
filter: Boolean,
posts: Paginator[UblogPost.PreviewPost],
langSelections: List[(Language, String)]
)(using ctx: Context) =
@@ -122,13 +124,14 @@ final class UblogUi(helpers: Helpers, atomUi: AtomUi)(picfitUrl: lila.core.misc.
st.title := "Lichess community blogs"
).some
)
.hrefLangs(lila.ui.LangPath(langHref(routes.Ublog.communityAll()))):
.hrefLangs(lila.ui.LangPath(langHref(routes.Ublog.communityAll(filter)))):
main(cls := "page-menu")(
menu(Right("community")),
div(cls := "page-menu__content box box-pad ublog-index")(
boxTop(
h1(trans.ublog.communityBlogs()),
h1(cls := "collapsible")("Recent posts"),
div(cls := "box__top__actions")(
filterAndSort(filter.some, none, (f, _) => routes.Ublog.communityLang(languageOrAll, f)),
lila.ui.bits.mselect(
"ublog-lang",
language.fold(trans.site.allLanguages.txt())(langList.nameByLanguage),
@@ -136,8 +139,8 @@ final class UblogUi(helpers: Helpers, atomUi: AtomUi)(picfitUrl: lila.core.misc.
.map: (languageSel, name) =>
a(
href := {
if languageSel == Language("all") then routes.Ublog.communityAll()
else routes.Ublog.communityLang(languageSel)
if languageSel == Language("all") then routes.Ublog.communityAll(filter)
else routes.Ublog.communityLang(languageSel, filter)
},
cls := (languageSel == languageOrAll).option("current")
)(name)
@@ -152,7 +155,9 @@ final class UblogUi(helpers: Helpers, atomUi: AtomUi)(picfitUrl: lila.core.misc.
posts,
p =>
language
.fold(routes.Ublog.communityAll(p))(l => routes.Ublog.communityLang(l, p))
.fold(routes.Ublog.communityAll(filter, p))(l =>
routes.Ublog.communityLang(l, filter, p)
)
.url
)
)
@@ -169,7 +174,7 @@ final class UblogUi(helpers: Helpers, atomUi: AtomUi)(picfitUrl: lila.core.misc.
div(cls := "page-menu__content box box-pad ublog-index")(
boxTop(
h1(
ctx.isnt(user).option(frag(userLink(user), "'s ")),
ctx.isnt(user).option(frag(userLink(user, withFlair = false), "'s ")),
trans.ublog.drafts()
),
div(cls := "box__top__actions")(
@@ -183,88 +188,145 @@ final class UblogUi(helpers: Helpers, atomUi: AtomUi)(picfitUrl: lila.core.misc.
posts.currentPageResults.map { card(_, url) },
pagerNext(posts, np => routes.Ublog.drafts(user.username, np).url)
)
else
div(cls := "ublog-index__posts--empty"):
trans.ublog.noDrafts()
else div(cls := "ublog-index__posts--empty")(trans.ublog.noDrafts())
)
)
def friends(posts: Paginator[UblogPost.PreviewPost])(using Context) = list(
title = "Friends blogs",
title = "Blog posts by friends",
posts = posts,
menuItem = "friends",
route = (p, _) => routes.Ublog.friends(p),
route = (p, _, _) => routes.Ublog.friends(p),
onEmpty = "Nothing to show. Follow some authors!"
)
def liked(posts: Paginator[UblogPost.PreviewPost])(using Context) = list(
title = "Liked blog posts",
title = trans.ublog.likedBlogs.txt(),
posts = posts,
menuItem = "liked",
route = (p, _) => routes.Ublog.liked(p),
route = (p, _, _) => routes.Ublog.liked(p),
onEmpty = "Nothing to show. Like some posts!"
)
def topic(top: UblogTopic, posts: Paginator[UblogPost.PreviewPost], byDate: Boolean)(using Context) =
def topic(top: UblogTopic, filter: Boolean, by: BlogsBy, posts: Paginator[UblogPost.PreviewPost])(using
Context
) =
list(
title = s"Blog posts about $top",
title = s"$top posts",
posts = posts,
menuItem = "topics",
route = (p, bd) => routes.Ublog.topic(top.value, p, ~bd),
route = (p, f, b) => routes.Ublog.topic(top.value, f, b, p),
onEmpty = "Nothing to show.",
byDate.some
filterOpt = filter.some,
byOpt = by.some
)
def month(yearMonth: YearMonth, posts: Paginator[UblogPost.PreviewPost])(using Context) =
def month(yearMonth: YearMonth, filter: Boolean, by: BlogsBy, posts: Paginator[UblogPost.PreviewPost])(using
Context
) =
list(
title = s"Top posts of $yearMonth",
title = s"$yearMonth posts",
posts = posts,
menuItem = "best-of",
route = (p, _) => routes.Ublog.bestOfMonth(yearMonth.getYear, yearMonth.getMonthValue, p),
menuItem = "by-month",
route = (p, f, b) => routes.Ublog.byMonth(yearMonth.getYear, yearMonth.getMonthValue, f, b, p),
onEmpty = "Nothing to show.",
header = div(cls := "ublog-index__calendar")(
h1(cls := "box__top")("Best blog posts by month"),
filterOpt = filter.some,
byOpt = by.some,
header = boxTop(cls := "ublog-index__calendar")(
h1(cls := "collapsible")(trans.ublog.byMonth()),
lila.ui.bits.calendarMselect(
helpers,
"best-of",
UblogBestOf.allYears,
(y, m) => routes.Ublog.bestOfMonth(y, m)
)(yearMonth)
"by-month",
UblogByMonth.allYears,
(y, m) => routes.Ublog.byMonth(y, m, filter, by)
)(yearMonth),
filterAndSort(
filter.some,
by.some,
(f, b) => routes.Ublog.byMonth(yearMonth.getYear, yearMonth.getMonthValue, f, b, 1)
)
).some
)
private def list(
title: String,
posts: Paginator[UblogPost.PreviewPost],
menuItem: String,
route: (Int, Option[Boolean]) => Call,
onEmpty: => Frag,
byDate: Option[Boolean] = None,
header: Option[Frag] = None
def search(
text: String,
by: BlogsBy,
paginator: Option[Paginator[UblogPost.PreviewPost]] = none
)(using Context) =
Page(title)
import BlogsBy.*
Page("Search")
.css("bits.ublog")
.js(posts.hasNextPage.option(infiniteScrollEsmInit)):
.js(paginator.exists(_.hasNextPage).option(infiniteScrollEsmInit)):
main(cls := "page-menu")(
menu(Right(menuItem)),
menu(Right("search")),
div(cls := "page-menu__content box box-pad ublog-index")(
header | boxTop(
h1(title),
byDate.map: v =>
span(
"Sort by ",
boxTop(
form(action := routes.Ublog.search(), cls := "search", method := "get")(
h1(cls := "collapsible")("Search"),
span(cls := "search-input")(
input(name := "text", value := text, size := "8", enterkeyhint := "search"),
submitButton(cls := "button", name := "by", value := by.toString)(dataIcon := Icon.Search)
),
span(cls := "search-sort")(
"Sort",
span(cls := "btn-rack")(
a(cls := s"btn-rack__btn${(!v).so(" active")}", href := route(1, false.some))("rank"),
a(cls := s"btn-rack__btn${v.so(" active")}", href := route(1, true.some))("date")
submitButton(btnCls(by == Score), name := "by", value := "Score")("score"),
submitButton(btnCls(by == Likes), name := "by", value := "Likes")("likes"),
by match
case Newest =>
submitButton(btnCls(true, "descending"), name := "by", value := "Oldest")("date")
case Oldest =>
submitButton(btnCls(true, "ascending"), name := "by", value := "Newest")("date")
case _ =>
submitButton(btnCls(false, "descending"), name := "by", value := "Newest")("date")
)
)
),
if posts.nbResults > 0 then
div(cls := "ublog-index__posts ublog-post-cards infinite-scroll")(
posts.currentPageResults.map { card(_, showAuthor = ShowAt.top) },
pagerNext(posts, np => route(np, byDate).url)
)
else div(cls := "ublog-index__posts--empty")(onEmpty)
),
paginator
.flatMap:
case pager if pager.nbResults > 0 =>
(pager.nbResults > 0).option:
div(cls := "ublog-index__posts ublog-post-cards infinite-scroll")(
pager.currentPageResults.map(card(_, showAuthor = ShowAt.top)),
pagerNext(
pager,
np => routes.Ublog.search(text, by, np).url
)
)
case _ => none
.getOrElse(div(cls := "ublog-index__posts--empty")("No results"))
)
)
def modShowCarousel(posts: UblogPost.CarouselPosts)(using Context) =
Page("Blog carousel")
.css("bits.ublog")
.js(Esm("bits.ublog")):
main(cls := "page-menu")(
bits.modMenu("carousel"),
div(cls := "page-menu__content box box-pad")(
div(cls := "ublog-index__posts ublog-mod-carousel")(
(posts.pinned ++ posts.queue).map: p =>
val by = userIdLink(
p.featured.map(_.by),
withFlair = false,
withOnline = false,
withPowerTip = false
)
div(
span(
p.featured
.so(_.until)
.map(until => label("Pinned by ", by, s" until ${showDate(until)}")),
p.featured.so(_.at).map(at => label("Added by ", by, s" ${showDate(at)}")),
form(action := routes.Ublog.modPull(p.id), method := "POST")(
input(tpe := "submit", cls := "pull", value := Icon.X)
)
),
card(p, showAuthor = ShowAt.top, showIntro = false)
)
)
)
)
@@ -272,8 +334,7 @@ final class UblogUi(helpers: Helpers, atomUi: AtomUi)(picfitUrl: lila.core.misc.
Page("All blog topics").css("bits.ublog"):
main(cls := "page-menu")(
menu(Right("topics")),
div(cls := "page-menu__content box")(
boxTop(h1(trans.ublog.blogTopics())),
div(cls := "page-menu__content box box-pad ublog-index")(
div(cls := "ublog-topics")(
tops.map { case UblogTopic.WithPosts(topic, posts, nb) =>
a(cls := "ublog-topics__topic", href := routes.Ublog.topic(topic.url))(
@@ -290,43 +351,10 @@ final class UblogUi(helpers: Helpers, atomUi: AtomUi)(picfitUrl: lila.core.misc.
)
)
def year(bests: Paginator[UblogBestOf.WithPosts])(using Context) =
Page("Best blogs by month")
.css("bits.ublog")
.js(infiniteScrollEsmInit):
main(cls := "page-menu")(
menu(Right("best-of")),
div(cls := "page-menu__content box")(
boxTop(h1("Best blog posts by month")),
div(cls := "ublog-topics infinite-scroll")(
bests.currentPageResults.map { case UblogBestOf.WithPosts(yearMonth, posts) =>
a(
cls := "ublog-topics__topic",
href := routes.Ublog.bestOfMonth(yearMonth.getYear, yearMonth.getMonthValue)
)(
h2(
s"Best of ${showYearMonth(yearMonth)}",
span(cls := "ublog-topics__topic__nb")(trans.site.more(), " »")
),
span(cls := "ublog-topics__topic__posts ublog-post-cards")(posts.map(miniCard))
)
},
pagerNext(bests, np => routes.Ublog.bestOfYear(np).url)
)
)
)
def urlOfBlog(blog: UblogBlog): Call = urlOfBlog(blog.id)
def urlOfBlog(blogId: UblogBlog.Id): Call = blogId match
case UblogBlog.Id.User(userId) => routes.Ublog.index(usernameOrId(userId))
private def tierForm(blog: UblogBlog) = postForm(action := routes.Ublog.setTier(blog.id.full)):
val form = lila.ublog.UblogForm.tier.fill(blog.tier)
frag(
span(dataIcon := Icon.Agent, cls := "text")("Set to:"),
form3.select(form("tier"), lila.ublog.UblogRank.Tier.options)
)
def menu(active: Either[UserId, String])(using ctx: Context) =
def isRight(s: String) = active.fold(_ => false, _ == s)
def isActive(s: String) = isRight(s).option("active")
@@ -336,42 +364,120 @@ final class UblogUi(helpers: Helpers, atomUi: AtomUi)(picfitUrl: lila.core.misc.
lila.ui.bits.pageMenuSubnav(
cls := "force-ltr",
ctx.kid.no.option(
a(
cls := community.option("active"),
href := langHref(routes.Ublog.communityAll())
)(trans.ublog.communityBlogs())
),
ctx.kid.no.option(
a(
cls := isActive("best-of"),
href := langHref(routes.Ublog.bestOfYear())
)("Best of")
),
ctx.kid.no.option(
a(cls := isActive("topics"), href := routes.Ublog.topics)(
trans.ublog.blogTopics()
frag(
a(
cls := community.option("active"),
href := langHref(routes.Ublog.communityAll())
)(trans.ublog.community()),
a(
cls := isActive("search"),
href := langHref(routes.Ublog.search())
)("Search"),
a(
cls := isActive("by-month"),
href := langHref(routes.Ublog.thisMonth())
)(trans.ublog.byMonth()),
a(cls := isActive("topics"), href := routes.Ublog.topics)(
trans.ublog.byTopic()
)
)
),
(ctx.isAuth && ctx.kid.no).option(
a(
cls := isActive("friends"),
href := routes.Ublog.friends()
)(trans.ublog.friendBlogs())
),
ctx.kid.no.option(
a(cls := isActive("liked"), href := routes.Ublog.liked())(
trans.ublog.likedBlogs()
)
),
ctx.me
.ifTrue(ctx.kid.no)
.map: me =>
a(cls := mine.option("active"), href := routes.Ublog.index(me.username))(trans.ublog.myBlog()),
a(cls := lichess.option("active"), href := routes.Ublog.index(UserName.lichess))(
trans.ublog.lichessBlog()
trans.ublog.byLichess()
),
ctx.kid.no.option(
frag(
div(cls := "sep"),
a(cls := isActive("liked"), href := routes.Ublog.liked())(
trans.ublog.myLikes()
),
ctx.me.map: me =>
frag(
a(
cls := isActive("friends"),
href := routes.Ublog.friends()
)(trans.ublog.myFriends()),
a(cls := mine.option("active"), href := routes.Ublog.index(me.username))(trans.ublog.myBlog())
)
)
)
)
private def list(
title: String,
posts: Paginator[UblogPost.PreviewPost],
menuItem: String,
route: (Int, Boolean, BlogsBy) => Call,
onEmpty: => Frag,
filterOpt: Option[Boolean] = None,
byOpt: Option[BlogsBy] = None,
header: Option[Frag] = None
)(using ctx: Context) =
val filter = filterOpt.getOrElse(true)
val by = byOpt.getOrElse(BlogsBy.Newest)
Page(title)
.css("bits.ublog")
.js(posts.hasNextPage.option(infiniteScrollEsmInit)):
main(cls := "page-menu")(
menu(Right(menuItem)),
div(cls := "page-menu__content box box-pad ublog-index")(
header | boxTop(
h1(cls := "collapsible")(title),
filterAndSort(filterOpt, byOpt, (f, b) => route(1, f, b))
),
if posts.nbResults > 0 && posts.currentPageResults.size > 0 then
div(cls := "ublog-index__posts ublog-post-cards infinite-scroll")(
posts.currentPageResults.map { card(_, showAuthor = ShowAt.top) },
pagerNext(posts, np => route(np, filter, by).url)
)
else div(cls := "ublog-index__posts--empty")(onEmpty)
)
)
private def filterAndSort(
filterOpt: Option[Boolean],
sortOpt: Option[BlogsBy],
route: (Boolean, BlogsBy) => Call
) =
import BlogsBy.*
val sort = sortOpt.getOrElse(Newest)
val filter = filterOpt.getOrElse(true)
div(cls := "filter-and-sort")(
filterOpt.isDefined.option(
span(
"Effort",
span(cls := "btn-rack")(
a(btnCls(!filter), href := route(false, sort))("low"),
a(btnCls(filter), href := route(true, sort))("high")
)
)
),
sortOpt.map: by =>
span(
"Sort",
span(cls := "btn-rack")(
a(btnCls(by == Likes), href := route(filter, Likes))("likes"),
by match
case Newest =>
a(btnCls(true, "descending"), href := route(filter, Oldest))("date")
case Oldest =>
a(btnCls(true, "ascending"), href := route(filter, Newest))("date")
case _ =>
a(btnCls(false, "descending"), href := route(filter, Newest))("date")
)
)
)
private def btnCls(active: Boolean, other: String = ""): Modifier =
cls := s"btn-rack__btn $other " + (if active then "lit" else "")
private def tierForm(blog: UblogBlog) = postForm(action := routes.Ublog.setTier(blog.id.full)):
val form = lila.ublog.UblogForm.tier.fill(blog.tier)
frag(
span(dataIcon := Icon.Agent, cls := "text")("Set to:"),
form3.select(form("tier"), lila.ublog.UblogBlog.Tier.options)
)
object atom:
def user(
user: User,
+15 -9
View File
@@ -51,7 +51,9 @@ trait UserHelper:
withTitle: Boolean = true,
truncate: Option[Int] = None,
params: String = "",
modIcon: Boolean = false
modIcon: Boolean = false,
withFlair: Boolean = true,
withPowerTip: Boolean = true
)(using Translate): Tag =
userIdOption
.flatMap(u => lightUserSync(u.id))
@@ -61,12 +63,13 @@ trait UserHelper:
username = user.name,
isPatron = user.isPatron,
title = user.title.ifTrue(withTitle),
flair = user.flair,
flair = if withFlair then user.flair else none,
cssClass = cssClass,
withOnline = withOnline,
truncate = truncate,
params = params,
modIcon = modIcon
modIcon = modIcon,
withPowerTip = withPowerTip
)
def lightUserLink(
@@ -112,12 +115,13 @@ trait UserHelper:
withTitle: Boolean = true,
withPerfRating: Option[Perf | UserPerfs] = None,
name: Option[Frag] = None,
params: String = ""
params: String = "",
withFlair: Boolean = true
)(using Translate): Tag =
a(
cls := userClass(user.id, none, withOnline, withPowerTip),
href := userUrl(user.username, params)
)(userLinkContent(user, withOnline, withTitle, withPerfRating, name))
)(userLinkContent(user, withOnline, withTitle, withPerfRating, name, withFlair))
def userSpan(
user: User,
@@ -138,12 +142,13 @@ trait UserHelper:
withOnline: Boolean = true,
withTitle: Boolean = true,
withPerfRating: Option[Perf | UserPerfs] = None,
name: Option[Frag] = None
name: Option[Frag] = None,
withFlair: Boolean = true
)(using Translate) = frag(
withOnline.so(lineIcon(user)),
withTitle.option(titleTag(user.title)),
name | user.username,
userFlair(user),
withFlair.option(userFlair(user)),
withPerfRating.map(userRating)
)
@@ -157,10 +162,11 @@ trait UserHelper:
title: Option[PlayerTitle],
flair: Option[Flair],
params: String,
modIcon: Boolean
modIcon: Boolean,
withPowerTip: Boolean = true
)(using Translate): Tag =
a(
cls := userClass(userId, cssClass, withOnline),
cls := userClass(userId, cssClass, withOnline, withPowerTip),
href := userUrl(username, params = params)
)(
withOnline.so(if modIcon then moderatorIcon else lineIcon(isPatron)),
+3 -1
View File
@@ -6,6 +6,7 @@ import scalalib.newtypes.SameRuntime
import lila.core.id.*
import lila.core.study.Order as StudyOrder
import lila.core.ublog.BlogsBy
object LilaRouter:
@@ -59,4 +60,5 @@ object LilaRouter:
given QueryStringBindable[Color] =
strQueryString[Color](Color.fromName, "Invalid chess color, should be white or black", _.name)
given QueryStringBindable[Uci] = strQueryString[Uci](Uci.apply, "Invalid UCI move", _.uci)
given QueryStringBindable[Uci] = strQueryString[Uci](Uci.apply, "Invalid UCI move", _.uci)
given QueryStringBindable[BlogsBy] = strQueryString[BlogsBy](BlogsBy.fromName, "Invalid order", _.toString)
+2
View File
@@ -13,6 +13,7 @@ package routes:
export lila.core.perf.PerfKey
export lila.core.socket.Sri
export lila.core.study.Order as StudyOrder
export lila.core.ublog.BlogsBy
export lila.ui.LilaRouter.given
package router.router:
@@ -28,6 +29,7 @@ package router.router:
export lila.core.perf.PerfKey
export lila.core.socket.Sri
export lila.core.study.Order as StudyOrder
export lila.core.ublog.BlogsBy
export lila.ui.LilaRouter.given
package router.team:
+42
View File
@@ -112,3 +112,45 @@ object bits:
)(content)
)
)
def modMenu(active: String)(using ctx: Context): Frag = ctx.me.foldUse(emptyFrag): me ?=>
pageMenuSubnav(
Granter(_.SeeReport)
.option(a(cls := itemCls(active, "report"), href := routes.Report.list)("Reports")),
Granter(_.PublicChatView)
.option(a(cls := itemCls(active, "public-chat"), href := routes.Mod.publicChat)("Public Chats")),
Granter(_.GamifyView)
.option(a(cls := itemCls(active, "activity"), href := routes.Mod.activity)("Mod activity")),
Granter(_.GamifyView)
.option(a(cls := itemCls(active, "queues"), href := routes.Mod.queues("month"))("Queues stats")),
Granter(_.GamifyView)
.option(a(cls := itemCls(active, "gamify"), href := routes.Mod.gamify)("Hall of fame")),
Granter(_.GamifyView)
.option(a(cls := itemCls(active, "log"), href := routes.Mod.log(me.username.some))("Mod logs")),
Granter(_.UserSearch)
.option(a(cls := itemCls(active, "search"), href := routes.Mod.search)("Search users")),
Granter(_.Admin).option(a(cls := itemCls(active, "notes"), href := routes.Mod.notes())("Mod notes")),
Granter(_.SetEmail)
.option(a(cls := itemCls(active, "email"), href := routes.Mod.emailConfirm)("Email confirm")),
Granter(_.Pages).option(a(cls := itemCls(active, "cms"), href := routes.Cms.index)("Pages")),
Granter(_.PracticeConfig)
.option(a(cls := itemCls(active, "practice"), href := routes.Practice.config)("Practice")),
Granter(_.ManageTournament)
.option(a(cls := itemCls(active, "tour"), href := routes.TournamentCrud.index(1))("Tournaments")),
Granter(_.ManageEvent)
.option(a(cls := itemCls(active, "event"), href := routes.Event.manager)("Events")),
Granter(_.ModerateBlog)
.option(a(cls := itemCls(active, "carousel"), href := routes.Ublog.modShowCarousel)("Blog carousel")),
Granter(_.MarkEngine)
.option(a(cls := itemCls(active, "irwin"), href := routes.Irwin.dashboard)("Irwin dashboard")),
Granter(_.MarkEngine)
.option(a(cls := itemCls(active, "kaladin"), href := routes.Irwin.kaladin)("Kaladin dashboard")),
Granter(_.Admin).option(a(cls := itemCls(active, "mods"), href := routes.Mod.table)("Mods")),
Granter(_.Presets)
.option(a(cls := itemCls(active, "presets"), href := routes.Mod.presets("PM"))("Msg presets")),
Granter(_.Settings)
.option(a(cls := itemCls(active, "setting"), href := routes.Dev.settings)("Settings")),
Granter(_.Cli).option(a(cls := itemCls(active, "cli"), href := routes.Dev.cli)("CLI"))
)
private def itemCls(active: String, item: String) = if active == item then "active" else ""
+1 -1
View File
@@ -19,7 +19,7 @@
"homepage": "https://lichess.org",
"packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af",
"engines": {
"node": ">=22.6",
"node": ">=24",
"pnpm": "10"
},
"pnpm": {
+28 -25
View File
@@ -55,10 +55,13 @@ importers:
version: 5.8.3
vitest:
specifier: ^3.1.3
version: 3.1.3(@types/node@22.14.1)(jsdom@26.0.0)(yaml@2.7.0)
version: 3.1.3(@types/node@24.0.1)(jsdom@26.0.0)(yaml@2.7.0)
bin:
dependencies:
'@types/node':
specifier: 24.0.1
version: 24.0.1
fast-xml-parser:
specifier: ^4.5.3
version: 4.5.3
@@ -992,12 +995,12 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@22.13.4':
resolution: {integrity: sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg==}
'@types/node@22.14.1':
resolution: {integrity: sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==}
'@types/node@24.0.1':
resolution: {integrity: sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==}
'@types/qrcode@1.5.5':
resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==}
@@ -2310,12 +2313,12 @@ packages:
undate@0.3.0:
resolution: {integrity: sha512-ssH8QTNBY6B+2fRr3stSQ+9m2NT8qTaun3ExTx5ibzYQvP7yX4+BnX0McNxFCvh6S5ia/DYu6bsCKQx/U4nb/Q==}
undici-types@6.20.0:
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.8.0:
resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==}
update-browserslist-db@1.1.3:
resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
hasBin: true
@@ -2830,17 +2833,17 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/node@22.13.4':
dependencies:
undici-types: 6.20.0
'@types/node@22.14.1':
dependencies:
undici-types: 6.21.0
'@types/node@24.0.1':
dependencies:
undici-types: 7.8.0
'@types/qrcode@1.5.5':
dependencies:
'@types/node': 22.13.4
'@types/node': 24.0.1
'@types/react@19.0.10':
dependencies:
@@ -2952,13 +2955,13 @@ snapshots:
chai: 5.2.0
tinyrainbow: 2.0.0
'@vitest/mocker@3.1.3(vite@6.1.1(@types/node@22.14.1)(yaml@2.7.0))':
'@vitest/mocker@3.1.3(vite@6.1.1(@types/node@24.0.1)(yaml@2.7.0))':
dependencies:
'@vitest/spy': 3.1.3
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 6.1.1(@types/node@22.14.1)(yaml@2.7.0)
vite: 6.1.1(@types/node@24.0.1)(yaml@2.7.0)
'@vitest/pretty-format@3.1.3':
dependencies:
@@ -4184,10 +4187,10 @@ snapshots:
undate@0.3.0: {}
undici-types@6.20.0: {}
undici-types@6.21.0: {}
undici-types@7.8.0: {}
update-browserslist-db@1.1.3(browserslist@4.25.0):
dependencies:
browserslist: 4.25.0
@@ -4200,13 +4203,13 @@ snapshots:
uuid@11.1.0: {}
vite-node@3.1.3(@types/node@22.14.1)(yaml@2.7.0):
vite-node@3.1.3(@types/node@24.0.1)(yaml@2.7.0):
dependencies:
cac: 6.7.14
debug: 4.4.0
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 6.1.1(@types/node@22.14.1)(yaml@2.7.0)
vite: 6.1.1(@types/node@24.0.1)(yaml@2.7.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -4221,20 +4224,20 @@ snapshots:
- tsx
- yaml
vite@6.1.1(@types/node@22.14.1)(yaml@2.7.0):
vite@6.1.1(@types/node@24.0.1)(yaml@2.7.0):
dependencies:
esbuild: 0.24.2
postcss: 8.5.3
rollup: 4.34.8
optionalDependencies:
'@types/node': 22.14.1
'@types/node': 24.0.1
fsevents: 2.3.3
yaml: 2.7.0
vitest@3.1.3(@types/node@22.14.1)(jsdom@26.0.0)(yaml@2.7.0):
vitest@3.1.3(@types/node@24.0.1)(jsdom@26.0.0)(yaml@2.7.0):
dependencies:
'@vitest/expect': 3.1.3
'@vitest/mocker': 3.1.3(vite@6.1.1(@types/node@22.14.1)(yaml@2.7.0))
'@vitest/mocker': 3.1.3(vite@6.1.1(@types/node@24.0.1)(yaml@2.7.0))
'@vitest/pretty-format': 3.1.3
'@vitest/runner': 3.1.3
'@vitest/snapshot': 3.1.3
@@ -4251,11 +4254,11 @@ snapshots:
tinyglobby: 0.2.13
tinypool: 1.0.2
tinyrainbow: 2.0.0
vite: 6.1.1(@types/node@22.14.1)(yaml@2.7.0)
vite-node: 3.1.3(@types/node@22.14.1)(yaml@2.7.0)
vite: 6.1.1(@types/node@24.0.1)(yaml@2.7.0)
vite-node: 3.1.3(@types/node@24.0.1)(yaml@2.7.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.14.1
'@types/node': 24.0.1
jsdom: 26.0.0
transitivePeerDependencies:
- jiti
+3
View File
@@ -1,3 +1,6 @@
useNodeVersion: 24.1.0
managePackageManagerVersions: true
updateNotified: false
packages:
- 'bin'
- 'ui/*'
+7 -7
View File
@@ -27,13 +27,13 @@ object Dependencies {
val lettuce = "io.lettuce" % "lettuce-core" % "6.7.1.RELEASE"
val nettyTransport =
("io.netty" % s"netty-transport-native-$notifier" % "4.2.2.Final").classifier(s"$os-$arch")
val lilaSearch = "com.github.lichess-org.lila-search" %% "client" % "3.1.9"
val munit = "org.scalameta" %% "munit" % "1.1.1" % Test
val uaparser = "org.uaparser" %% "uap-scala" % "0.19.0"
val apacheText = "org.apache.commons" % "commons-text" % "1.13.1"
val apacheMath = "org.apache.commons" % "commons-math3" % "3.6.1"
val bloomFilter = "com.github.alexandrnikitin" %% "bloom-filter" % "0.13.1_lila-1"
val kittens = "org.typelevel" %% "kittens" % "3.5.0"
val lilaSearch = "com.github.schlawg.lila-search" %% "client" % "v3.2.0-UBLOG"
val munit = "org.scalameta" %% "munit" % "1.1.1" % Test
val uaparser = "org.uaparser" %% "uap-scala" % "0.19.0"
val apacheText = "org.apache.commons" % "commons-text" % "1.13.1"
val apacheMath = "org.apache.commons" % "commons-math3" % "3.6.1"
val bloomFilter = "com.github.alexandrnikitin" %% "bloom-filter" % "0.13.1_lila-1"
val kittens = "org.typelevel" %% "kittens" % "3.5.0"
val scalacheck = "org.scalacheck" %% "scalacheck" % "1.18.1" % Test
val munitCheck = "org.scalameta" %% "munit-scalacheck" % "1.1.0" % Test
+6 -5
View File
@@ -1,16 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="communityBlogs">Community blogs</string>
<string name="friendBlogs">Friends blogs</string>
<string name="lichessBlog">Lichess blog</string>
<string name="community">Community</string>
<string name="byMonth">By month</string>
<string name="byTopic">By topic</string>
<string name="byLichess">By Lichess</string>
<string name="myFriends">My friends</string>
<string name="myLikes">My likes</string>
<string name="myBlog">My blog</string>
<string name="likedBlogs">Liked blog posts</string>
<string name="blogTopics">Blog topics</string>
<plurals name="blogPosts">
<item quantity="one">%s blog post</item>
<item quantity="other">%s blog posts</item>
</plurals>
<string name="lichessOfficialBlog">Lichess Official Blog</string>
<string name="continueReadingPost">Continue reading this post</string>
<string name="lichessBlogPostsFromXYear">Lichess blog posts in %s</string>
<string name="previousBlogPosts">Previous blog posts</string>
+11 -47
View File
@@ -168,9 +168,17 @@ async function parseScss(src: string, processed: Set<string>) {
// collect mixable scss color definitions from theme files
async function parseThemeColorDefs() {
async function loadThemeColors(themeFile: string) {
const text = await fs.promises.readFile(themeFile, 'utf8');
const colorMap = new Map<string, clr.Instance>();
for (const [, color, colorVal] of text.matchAll(/\s\$c-([-a-z0-9]+):\s*([^;]+);/g)) {
colorMap.set(color, clr(colorVal.trim()));
}
return colorMap;
}
const themes: string[] = ['dark'];
const defaultThemeColors = await loadThemeColorDefs(join(env.themeDir, '_default.scss'));
const defaultThemeColors = await loadThemeColors(join(env.themeDir, '_default.scss'));
themeColorMap.set('default', defaultThemeColors);
const themeFiles = await glob(join(env.themeDir, '_*.scss'), { absolute: false });
@@ -183,8 +191,9 @@ async function parseThemeColorDefs() {
if (theme === 'default') {
continue;
}
themes.push(theme);
themeColorMap.set(theme, await loadThemeColorDefs(themeFile, defaultThemeColors));
themeColorMap.set(theme, await loadThemeColors(themeFile));
}
for (const theme of themes) {
@@ -196,51 +205,6 @@ async function parseThemeColorDefs() {
}
}
async function loadThemeColorDefs(themeFile: string, fallbackColors: Map<string, clr.Instance> = new Map()) {
const text = await fs.promises.readFile(themeFile, 'utf8');
const capturedColors = new Map<string, string>();
for (const match of text.matchAll(/\s\$c-([-a-z0-9]+):\s*([^;]+);/g)) {
capturedColors.set(match[1], match[2]);
}
const colorMap = new Map<string, clr.Instance>();
for (const [color, colorVal] of capturedColors) {
colorMap.set(color, resolveVariablesInValue(colorVal, capturedColors, fallbackColors));
}
return colorMap;
}
// if value is a variable (i.e. starts with $), recursively resolve it to the value of the referenced variable
// if the variable is unknown, try to fall back to fallbackColors
function resolveVariablesInValue(
value: string,
variables: Map<string, string>,
fallbackColors: Map<string, clr.Instance>,
) {
const visitedVariables = new Set<string>();
while (value.startsWith('$')) {
const colorName = value.substring(3);
const resolvedValue = variables.get(colorName);
if (!resolvedValue) {
const fallbackColor = fallbackColors.get(colorName);
if (!fallbackColor) {
env.log(`${errorMark} Failed to resolve variable: '${c.magenta(value)}'`, 'sass');
return clr('black');
}
return fallbackColor;
}
if (visitedVariables.has(resolvedValue)) {
env.log(`${errorMark} Detected loop resolving variable: '${c.magenta(value)}'`, 'sass');
return clr('black');
}
visitedVariables.add(resolvedValue);
value = resolvedValue;
}
return clr(value);
}
// given color definitions and mix instructions, build mixed color css variables in themed scss mixins
async function buildColorMixes() {
const out = fs.createWriteStream(join(env.themeGenDir, '_mix.scss'));
+12 -10
View File
@@ -5597,10 +5597,14 @@ interface I18n {
blogPosts: I18nPlural;
/** Our simple tips to write great blog posts */
blogTips: string;
/** Blog topics */
blogTopics: string;
/** Community blogs */
communityBlogs: string;
/** By Lichess */
byLichess: string;
/** By month */
byMonth: string;
/** By topic */
byTopic: string;
/** Community */
community: string;
/** Continue reading this post */
continueReadingPost: string;
/** Enable comments */
@@ -5615,8 +5619,6 @@ interface I18n {
drafts: string;
/** Edit your blog post */
editYourBlogPost: string;
/** Friends blogs */
friendBlogs: string;
/** Image alternative text */
imageAlt: string;
/** Image credit */
@@ -5625,16 +5627,16 @@ interface I18n {
inappropriateContentAccountClosed: string;
/** Latest blog posts */
latestBlogPosts: string;
/** Lichess blog */
lichessBlog: string;
/** Lichess blog posts in %s */
lichessBlogPostsFromXYear: I18nFormat;
/** Lichess Official Blog */
lichessOfficialBlog: string;
/** Liked blog posts */
likedBlogs: string;
/** My blog */
myBlog: string;
/** My friends */
myFriends: string;
/** My likes */
myLikes: string;
/** %s views */
nbViews: I18nPlural;
/** New post */
+2 -2
View File
@@ -28,7 +28,7 @@ $top-height: 3.2rem;
display: flex;
input {
@extend %box-radius-left;
@extend %box-radius-inline-start;
flex: 1 1 100%;
height: $top-height;
@@ -40,7 +40,7 @@ $top-height: 3.2rem;
}
.button {
@extend %box-radius-right;
@extend %box-radius-inline-end;
padding: 0 1.5em;
border-inline-start: 0;
+1
View File
@@ -5,4 +5,5 @@
@import '../../../lib/css/component/markdown';
@import '../../../lib/css/component/mselect';
@import '../../../lib/css/component/calendar-mselect';
@import '../../../lib/css/component/ublog-card';
@import '../ublog/ublog';
+66 -31
View File
@@ -160,47 +160,82 @@
}
}
.ublog-post__mod-tools {
@extend %flex-column;
align-items: stretch;
padding: 1.5em;
gap: 1.5em;
border: $border-width $border-style $c-accent;
> div {
#mod-tools {
display: flex;
gap: 2rem;
@media (max-width: at-most($small)) {
flex-direction: column;
gap: 1rem;
}
margin-bottom: 2rem;
fieldset,
div {
@extend %flex-column;
align-items: stretch;
justify-content: space-between;
gap: 1rem;
}
fieldset {
border: $border-width $border-style $c-border;
padding: 1rem 1.5rem 1.5rem;
}
legend {
@extend %flex-between;
gap: 1.5em;
align-items: center;
padding: 0 0.5rem;
gap: 1ch;
height: 2rem;
button {
padding: 0.3rem 0.6rem;
}
label {
font-size: 1.2rem;
}
}
span {
@extend %flex-center;
gap: 1em;
}
.automod {
@extend %flex-between;
input {
flex: auto;
padding: 4px 6px;
}
i {
input[type]:not([type='text']) {
cursor: pointer;
font-size: 1.6rem;
font-weight: 700;
}
i::before {
font-size: 1.4rem;
vertical-align: -2px;
.btn-rack {
margin: 0 auto;
}
i.flagged {
color: $c-brag;
}
i.offtopic {
color: $c-bad;
}
legend {
width: 100%;
padding-inline-start: 1em;
> span {
@extend %flex-between;
.carousel-fields {
align-items: center;
span {
gap: 1ch;
}
}
input[type='number'] {
width: 64px;
padding: 0.5em;
.submit-fields {
flex: auto;
&:has(.submit:not(.none)) {
border-color: $c-primary;
}
> span {
@extend %flex-center;
gap: 1rem;
height: 2rem;
}
input[type='checkbox'] {
flex: none;
width: 1.2rem;
height: 1.2rem;
}
.commercial {
color: $c-secondary;
}
.flagged {
color: $c-bad;
}
.empty {
color: $c-font-dimmer;
}
}
}
+4 -3
View File
@@ -32,15 +32,16 @@
}
}
&__topic {
display: block;
padding: 1.2em var(---box-padding) 2.3em var(---box-padding);
@extend %flex-column;
gap: 1em;
padding: 1em 1.5em 1.5em;
border-radius: $box-radius-size;
@include transition(background);
&:hover {
background: $m-primary_bg--mix-18;
}
h2 {
@extend %flex-between;
margin-bottom: 1rem;
span {
font-size: 1rem;
}
+161 -14
View File
@@ -1,15 +1,50 @@
@import '../../../lib/css/component/ublog-card';
@import 'markup';
@import 'topic';
@import 'post';
.ublog-index {
&__calendar {
margin-bottom: 2em;
@extend %flex-column;
gap: 2rem;
padding-top: 2rem;
padding-bottom: 3rem;
> * {
padding: 0;
margin: 0;
}
#form3-tier {
border: none;
background: none;
}
}
.ublog-index .box__top {
padding: 0;
min-height: 5rem;
gap: 2rem;
// forgive all the overrides, container gap is easier for me than the element margin hats and chins
* {
margin: 0;
}
div,
span,
form,
h1,
a {
@extend %flex-center;
}
.box__top__actions {
@extend %flex-center;
justify-content: center;
gap: 2rem;
.mselect__label {
padding: 0.6rem 1em;
}
.atom {
font-size: 2.6em;
color: $c-font-dimmer;
@@ -18,19 +53,131 @@
}
}
}
h1 {
.user-link {
color: $c-link;
.search {
flex: auto;
gap: 2rem;
.search-input {
align-items: stretch;
flex: auto;
input {
@extend %box-radius-inline-start;
flex: auto;
font-size: 1.2em;
}
.button {
@extend %box-radius-inline-end;
padding: 0 1rem;
border-inline-start: 0;
}
}
&:focus-within .button {
outline: auto;
}
.search-sort {
flex-wrap: nowrap;
white-space: nowrap;
gap: 0.6em;
}
}
#form3-tier {
border: none;
background: none;
.filter-and-sort {
gap: 2rem;
> span {
flex-wrap: nowrap;
white-space: nowrap;
gap: 0.6em;
}
}
&__posts {
&--empty {
margin: 20vh auto;
text-align: center;
.calendar-mselect {
flex: auto;
padding: 0.2em;
gap: 0;
div {
gap: 0.5em;
}
label {
padding: 0.2em;
}
}
.descending::after {
margin-inline-start: 1ch;
font-family: 'lichess';
content: $licon-DownTriangle;
}
.ascending::after {
margin-inline-start: 1ch;
font-family: 'lichess';
content: $licon-UpTriangle;
}
@media (max-width: at-most($x-large)) {
h1.collapsible {
display: none;
}
.filter-and-sort,
.search-sort,
.box__top__actions {
margin: 0 auto;
}
}
}
.ublog-mod-carousel {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
grid-gap: 3rem;
div {
@extend %flex-column;
gap: 1rem;
}
span {
@extend %flex-between;
font-size: 0.9em;
gap: 1ch;
}
.pull {
padding: 2px;
font-family: 'lichess';
font-size: 1rem;
color: $c-bad;
padding: 1.5px;
border-radius: 50%;
border: 2px solid $c-bad;
cursor: pointer;
&:hover {
filter: brightness(1.2);
}
}
.user-link {
color: $c-primary;
}
}
.ublog-index__posts--empty {
margin: 20vh auto;
text-align: center;
}
.btn-rack {
border: none;
}
.btn-rack__btn {
border: solid 1px $m-border--fade-50;
padding: 0.2em 0.8em;
&.lit {
color: #fff;
background: $m-primary--fade-10;
border-color: $m-primary--fade-50;
}
}
+58 -4
View File
@@ -1,6 +1,6 @@
import * as xhr from 'lib/xhr';
import { alert, prompt } from 'lib/view/dialogs';
import { throttlePromiseDelay } from 'lib/async';
import { info } from 'lib/view/dialogs';
site.load.then(() => {
$('.flash').addClass('fade');
@@ -44,7 +44,61 @@ site.load.then(() => {
$('#form3-tier').on('change', function (this: HTMLSelectElement) {
(this.parentNode as HTMLFormElement).submit();
});
document
.querySelectorAll<HTMLElement>('.automod *[title]')
.forEach(el => el.addEventListener('click', () => info(el.title)));
rewireModTools();
});
type SubmitForm = {
quality?: string;
evergreen?: boolean;
flagged?: string;
commercial?: string;
featured?: boolean;
featuredUntil?: number;
};
function rewireModTools() {
const modToolsContainer = document.querySelector<HTMLElement>('#mod-tools-container');
if (!modToolsContainer?.firstElementChild) return;
const modTools = modToolsContainer.firstElementChild as HTMLElement;
const submitBtn = modTools.querySelector<HTMLButtonElement>('.submit')!;
const submit = async (o: SubmitForm) => {
const rsp = await xhr.textRaw(modTools.dataset.url!, {
headers: { 'Content-Type': 'application/json' },
method: 'POST',
body: JSON.stringify(o),
});
if (!rsp.ok) return alert(`Error ${rsp.status}: ${rsp.statusText}`);
modToolsContainer.innerHTML = await rsp.text();
rewireModTools();
};
modTools
.querySelectorAll<HTMLButtonElement>('.quality-btn')
.forEach(btn => btn.addEventListener('click', () => submit({ quality: btn.value })));
const submitFields = modTools.querySelector<HTMLElement>('.submit-fields')!;
submitFields.querySelectorAll<HTMLInputElement>('input').forEach(input =>
input.addEventListener('input', () => {
input.parentElement!.classList.toggle('empty', !input.value.trim());
submitBtn.classList.remove('none');
submitBtn.disabled = false;
}),
);
submitBtn.addEventListener('click', () => {
const form: Record<string, any> = {};
for (const input of submitFields.querySelectorAll<HTMLInputElement>('input')) {
form[input.id] = input.type === 'checkbox' ? input.checked : input.value;
}
submit(form);
});
modTools
.querySelector<HTMLElement>('.carousel-add-btn')
?.addEventListener('click', () => submit({ featured: true }));
modTools
.querySelector<HTMLElement>('.carousel-remove-btn')
?.addEventListener('click', () => submit({ featured: false }));
modTools.querySelector<HTMLElement>('.carousel-pin-btn')?.addEventListener('click', async () => {
const days = await prompt('How many days?', '7', (n: string) => Number(n) > 0 && Number(n) < 31);
if (days) submit({ featured: true, featuredUntil: Number(days) });
});
}
+2 -8
View File
@@ -2,8 +2,9 @@
set -e
MIN_NODE="v22.6.0"
cd "$(dirname "${BASH_SOURCE:-$0}")/.build"
MIN_NODE="$(cat ../../.node-version)"
CUR_NODE=$(node -v 2>/dev/null || echo "v0.0.0")
LOWEST="$(printf "%s\n%s" "$MIN_NODE" "$CUR_NODE" | sort -V | head -n 1)"
if [ "$LOWEST" != "$MIN_NODE" ]; then
@@ -11,13 +12,6 @@ if [ "$LOWEST" != "$MIN_NODE" ]; then
exit 1
fi
cd "$(dirname "${BASH_SOURCE:-$0}")/.build"
if [[ " $* " != *" --no-corepack "* ]]; then
if ! corepack enable >/dev/null 2>&1; then
echo "Corepack not available. Try a userland node such as nvm."
fi
fi
if ! yes | pnpm install --ignore-workspace >/dev/null 2>&1; then
pnpm install --loglevel debug --ignore-workspace
exit $?
+12
View File
@@ -10,6 +10,18 @@
/* helps with clipping background into border-radius */
}
%box-radius-inline-start {
border-radius: 0;
border-start-start-radius: $box-radius-size;
border-end-start-radius: $box-radius-size;
}
%box-radius-inline-end {
border-radius: 0;
border-start-end-radius: $box-radius-size;
border-end-end-radius: $box-radius-size;
}
%box-radius-left {
border-radius: $box-radius-size 0 0 $box-radius-size;
}
+3 -3
View File
@@ -30,13 +30,13 @@
}
&:first-child {
@extend %box-radius-left;
@extend %box-radius-inline-start;
}
&:last-child {
@extend %box-radius-right;
@extend %box-radius-inline-end;
border: 0;
border-inline-start: 0;
}
i {
+6 -4
View File
@@ -10,7 +10,7 @@
$site-hue: 37;
// top level $c-variables must be valid css format (rgba, hsla, hex) and are mixable by ui/build
// top level $c-variables must be valid css format (rgba, hsla, hex) to be mixable by ui/build
$c-bg: hsl(37, 7%, 14%); // $c-bg-high
$c-bg-page: hsl(37, 10%, 8%);
@@ -19,9 +19,11 @@ $c-bg-zebra: hsl(37, 5%, 19%);
$c-bg-zebra2: hsl(37, 5%, 24%);
$c-bg-popup: hsl(37, 7%, 22%);
$c-body-gradient: hsl(37, 12%, 16%);
$c-border: hsl(0, 0%, 25%);
$c-font: hsl(0, 0%, 73%);
$c-primary: hsl(209, 79%, 56%);
$c-secondary: hsl(88, 62%, 37%);
$c-good: hsl(88, 62%, 37%);
$c-accent: hsl(22, 100%, 42%);
$c-bad: hsl(0, 60%, 50%);
$c-brag: hsl(37, 74%, 43%);
@@ -30,7 +32,6 @@ $c-shade: hsl(0, 0%, 30%);
$c-inaccuracy: hsl(202, 78%, 62%);
$c-mistake: hsl(41, 100%, 45%);
$c-blunder: hsl(0, 69%, 60%);
$c-good: hsl(88, 62%, 37%);
$c-brilliant: hsl(129, 71%, 45%);
$c-interesting: hsl(307, 80%, 70%);
$c-paper: hsl(60, 56%, 91%);
@@ -82,7 +83,7 @@ $c-clearer: #fff;
--c-chat-mention-bg: rgba(59, 92, 22, 0.4);
--c-fancy: hsl(294, 62%, 48%);
--c-border: hsl(0, 0%, 25%);
--c-border: #{$c-border};
--c-border-page: hsl(0, 0%, 22%);
--c-border-tour: #{mix(hsl(0, 0%, 22%), $c-bg-page, 50)};
--c-border-light: hsl(0, 0%, 40%);
@@ -91,6 +92,7 @@ $c-clearer: #fff;
--c-secondary: #{$c-secondary};
--c-secondary-dim: #{_dim($c-secondary, 20%)};
--c-secondary-dimmer: #{_dim($c-secondary, 40%)};
--c-good: #{$c-secondary};
--c-accent: #{$c-accent};
--c-link: #{$c-primary};
--c-link-dim: var(--c-primary-dim);
@@ -119,7 +121,7 @@ $c-clearer: #fff;
--c-inaccuracy: #{$c-inaccuracy};
--c-mistake: #{$c-mistake};
--c-blunder: #{$c-blunder};
--c-good-move: #{$c-good};
--c-good-move: #{$c-secondary};
--c-brilliant: #{$c-brilliant};
--c-interesting: #{$c-interesting};
--c-pool-button: #{hsla($site-hue, 7%, 19%, 66%)};
+1 -2
View File
@@ -22,10 +22,10 @@ $c-shade: hsl(0, 0%, 84%);
$c-inaccuracy: hsl(202, 78%, 40%);
$c-mistake: hsl(41, 100%, 35%);
$c-blunder: hsl(0, 68%, 50%);
$c-good: $c-secondary;
$c-brilliant: hsl(129, 71%, 30%);
$c-interesting: hsl(307, 80%, 59%);
$c-paper: hsl(60, 56%, 86%);
$c-border: hsl(0, 0%, 85%);
$c-dimmer: #fff;
$c-clearer: #000;
@@ -55,7 +55,6 @@ html.light {
--c-chat-mention-bg: rgba(161, 194, 124, 0.4);
--c-fancy: hsl(294, 61%, 62%);
--c-border: hsl(0, 0%, 85%);
--c-border-page: hsl(0, 0%, 80%);
--c-border-tour: #{mix(hsl(0, 0%, 80%), $c-bg-page, 50)};
--c-border-light: hsl(0, 0%, 80%);