mirror of
https://github.com/lichess-org/lila.git
synced 2026-05-26 13:51:00 +00:00
make blogs more discoverable
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
v22.17.0
|
||||
v24.1.0
|
||||
|
||||
@@ -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
@@ -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] =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -34,5 +34,6 @@ trait IrcApi:
|
||||
slug: String,
|
||||
title: String,
|
||||
intro: String,
|
||||
topic: String
|
||||
topic: String,
|
||||
automod: Option[String]
|
||||
): Funit
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")(
|
||||
|
||||
@@ -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")(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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]] =
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -19,7 +19,7 @@
|
||||
"homepage": "https://lichess.org",
|
||||
"packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af",
|
||||
"engines": {
|
||||
"node": ">=22.6",
|
||||
"node": ">=24",
|
||||
"pnpm": "10"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
Generated
+28
-25
@@ -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
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
useNodeVersion: 24.1.0
|
||||
managePackageManagerVersions: true
|
||||
updateNotified: false
|
||||
packages:
|
||||
- 'bin'
|
||||
- 'ui/*'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
Vendored
+12
-10
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 +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 $?
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%)};
|
||||
|
||||
@@ -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%);
|
||||
|
||||
Reference in New Issue
Block a user