ublog topics page WIP

pull/9768/head
Thibault Duplessis 2021-09-09 16:00:05 +02:00
parent 4282b6bcf3
commit 1802221a93
18 changed files with 227 additions and 76 deletions

View File

@ -226,10 +226,18 @@ final class Ublog(env: Env) extends LilaController(env) {
}
}
def topics = Open { implicit ctx =>
NotForKids {
env.ublog.topic.withPosts map { topics =>
Ok(html.ublog.index.topics(topics))
}
}
}
def topic(str: String, page: Int) = Open { implicit ctx =>
NotForKids {
Reasonable(page, 15) {
UblogPost.Topic.fromUrl(str) ?? { top =>
lila.ublog.UblogTopic.fromUrl(str) ?? { top =>
env.ublog.paginator.liveByTopic(top, page) map { posts =>
Ok(html.ublog.index.topic(top, posts))
}

View File

@ -13,6 +13,7 @@ object bits {
def menu(year: Option[Int], active: Option[String])(implicit ctx: Context) =
st.nav(cls := "page-menu__menu subnav")(
a(cls := active.has("community").option("active"), href := routes.Ublog.community())("Community blogs"),
a(cls := active.has("topics").option("active"), href := routes.Ublog.topics)("Blog topics"),
a(cls := active.has("friends").option("active"), href := routes.Ublog.friends())("Friends blogs"),
a(cls := active.has("liked").option("active"), href := routes.Ublog.liked())("Liked blog posts"),
ctx.me map { me =>

View File

@ -9,7 +9,7 @@ import lila.app.ui.ScalatagsTemplate._
import lila.common.Captcha
import lila.i18n.LangList
import lila.ublog.UblogForm.UblogPostData
import lila.ublog.UblogPost
import lila.ublog.{ UblogPost, UblogTopic }
import lila.user.User
object form {
@ -117,7 +117,7 @@ object form {
)
},
form3.group(form("topics"), frag("Select the topics your post is about"))(
form3.textarea(_)(dataRel := UblogPost.Topic.all.mkString(","))
form3.textarea(_)(dataRel := UblogTopic.all.mkString(","))
),
captcha.fold(views.html.base.captcha.hiddenEmpty(form)) { c =>
views.html.base.captcha(form, c)

View File

@ -1,14 +1,14 @@
package views.html.ublog
import controllers.routes
import play.api.mvc.Call
import lila.api.Context
import lila.app.templating.Environment._
import lila.app.ui.ScalatagsTemplate._
import lila.common.paginator.Paginator
import lila.ublog.UblogPost
import lila.ublog.{ UblogPost, UblogTopic }
import lila.user.User
import play.api.mvc.Call
object index {
@ -59,10 +59,10 @@ object index {
onEmpty = "Nothing to show. Like some posts!"
)
def topic(top: UblogPost.Topic, posts: Paginator[UblogPost.PreviewPost])(implicit ctx: Context) = list(
def topic(top: UblogTopic, posts: Paginator[UblogPost.PreviewPost])(implicit ctx: Context) = list(
title = s"Blog posts about $top",
posts = posts,
menuItem = top.value,
menuItem = "topics",
route = p => routes.Ublog.topic(top.value, p),
onEmpty = "Nothing to show."
)
@ -75,6 +75,34 @@ object index {
onEmpty = "Nothing to show."
)
def topics(tops: List[UblogTopic.WithPosts])(implicit ctx: Context) =
views.html.base.layout(
moreCss = cssTag("ublog"),
title = "All blog topics"
) {
main(cls := "page-menu")(
views.html.blog.bits.menu(none, "topics".some),
div(cls := "page-menu__content box box-pad ublog-index")(
div(cls := "box__top")(h1("All blog topics")),
div(cls := "ublog-topics")(
tops.map { case UblogTopic.WithPosts(topic, posts, nb) =>
st.section(cls := "ublog-topics__topic")(
h2(
a(href := routes.Ublog.topic(topic.url))(
strong(topic.value),
span(cls := "ublog-topics__topic__nb")(trans.ublog.viewAllNbPosts(nb), " »")
)
),
div(cls := "ublog-topics__topic__posts ublog-post-cards")(
posts map { postView.miniCard(_, showAuthor = true) }
)
)
}
)
)
)
}
private def list(
title: String,
posts: Paginator[UblogPost.PreviewPost],

View File

@ -123,6 +123,18 @@ object post {
)
)
def miniCard(
post: UblogPost.BasePost,
makeUrl: UblogPost.BasePost => Call = urlOfPost,
showAuthor: Boolean = false
)(implicit ctx: Context) =
a(cls := "ublog-post-card ublog-post-card--mini", href := makeUrl(post))(
thumbnail(post, _.Small)(cls := "ublog-post-card__image"),
h3(cls := "ublog-post-card__title")(post.title),
post.lived map { live => semanticDate(live.at)(ctx.lang)(cls := "ublog-post-card__over-image") },
showAuthor option userIdSpanMini(post.created.by)(ctx.lang)(cls := "ublog-post-card__over-image")
)
def urlOfPost(post: UblogPost.BasePost) = post.blog match {
case UblogBlog.Id.User(userId) =>
routes.Ublog.post(usernameOrId(userId), post.slug, post.id.value)

View File

@ -97,6 +97,7 @@ GET /blog controllers.Blog.index(page: Int ?= 1)
GET /blog/all controllers.Blog.all
GET /blog/$year<2\d{3}> controllers.Blog.year(year: Int)
GET /blog/discuss/:id controllers.Blog.discuss(id: String)
GET /blog/topic controllers.Ublog.topics
GET /blog/topic/:topic controllers.Ublog.topic(topic: String, page: Int ?= 1)
GET /blog/:id/:slug controllers.Blog.show(id: String, slug: String, ref: Option[String] ?= None)
GET /blog.atom controllers.Blog.atom

View File

@ -2275,6 +2275,7 @@ val `noDrafts` = new I18nKey("ublog:noDrafts")
val `latestBlogPosts` = new I18nKey("ublog:latestBlogPosts")
val `publishedNbBlogPosts` = new I18nKey("ublog:publishedNbBlogPosts")
val `nbViews` = new I18nKey("ublog:nbViews")
val `viewAllNbPosts` = new I18nKey("ublog:viewAllNbPosts")
}
}

View File

@ -110,10 +110,9 @@ final class PlayerRepo(coll: Coll)(implicit ec: scala.concurrent.ExecutionContex
teamId: TeamID
): Fu[TeamBattle.TeamInfo] = {
coll
.aggregateWith[Bdoc]() { framework =>
.aggregateOne() { framework =>
import framework._
List(
Match(selectTour(tourId) ++ $doc("t" -> teamId)),
Match(selectTour(tourId) ++ $doc("t" -> teamId)) -> List(
Sort(Descending("m")),
Facet(
List(
@ -130,20 +129,19 @@ final class PlayerRepo(coll: Coll)(implicit ec: scala.concurrent.ExecutionContex
)
)
}
.headOption
.map {
_.flatMap { doc =>
for {
aggs <- doc.getAsOpt[List[Bdoc]]("agg")
agg <- aggs.headOption
nbPlayers <- agg.int("nb")
rating = agg.double("rating").??(math.round)
perf = agg.double("perf").??(math.round)
score = agg.double("score").??(math.round)
topPlayers <- doc.getAsOpt[List[Player]]("topPlayers")
} yield TeamBattle.TeamInfo(teamId, nbPlayers, rating.toInt, perf.toInt, score.toInt, topPlayers)
} | TeamBattle.TeamInfo(teamId, 0, 0, 0, 0, Nil)
.map { docO =>
for {
doc <- docO
aggs <- doc.getAsOpt[List[Bdoc]]("agg")
agg <- aggs.headOption
nbPlayers <- agg.int("nb")
rating = agg.double("rating").??(math.round)
perf = agg.double("perf").??(math.round)
score = agg.double("score").??(math.round)
topPlayers <- doc.getAsOpt[List[Player]]("topPlayers")
} yield TeamBattle.TeamInfo(teamId, nbPlayers, rating.toInt, perf.toInt, score.toInt, topPlayers)
}
.dmap(_ | TeamBattle.TeamInfo(teamId, 0, 0, 0, 0, Nil))
}
def bestTeamPlayers(tourId: Tournament.ID, teamId: TeamID, nb: Int): Fu[List[Player]] =

View File

@ -23,6 +23,8 @@ final class Env(
private val colls = new UblogColls(db(CollName("ublog_blog")), db(CollName("ublog_post")))
val topic = wire[UblogTopicApi]
val rank = wire[UblogRank]
val api = wire[UblogApi]

View File

@ -20,9 +20,9 @@ private object UblogBsonHandlers {
implicit val blogBSONHandler = Macros.handler[UblogBlog]
implicit val postIdBSONHandler = stringAnyValHandler[UblogPost.Id](_.value, UblogPost.Id)
implicit val topicBsonHandler = stringAnyValHandler[UblogPost.Topic](_.value, UblogPost.Topic.apply)
implicit val topicsBsonHandler = implicitly[BSONReader[List[UblogPost.Topic]]]
.afterRead(_.filter(t => UblogPost.Topic.exists(t.value)))
implicit val topicBsonHandler = stringAnyValHandler[UblogTopic](_.value, UblogTopic.apply)
implicit val topicsBsonHandler = implicitly[BSONReader[List[UblogTopic]]]
.afterRead(_.filter(t => UblogTopic.exists(t.value)))
implicit val langBsonHandler = stringAnyValHandler[Lang](_.code, Lang.apply)
implicit val recordedBSONHandler = Macros.handler[Recorded]
implicit val likesBSONHandler = intAnyValHandler[Likes](_.value, Likes)

View File

@ -69,7 +69,7 @@ object UblogForm {
intro = intro,
markdown = markdown,
language = LangList.removeRegion(realLanguage.orElse(user.realLang) | defaultLang),
topics = topics ?? UblogPost.Topic.fromStrList,
topics = topics ?? UblogTopic.fromStrList,
image = none,
live = false,
created = UblogPost.Recorded(user.id, DateTime.now),
@ -85,7 +85,7 @@ object UblogForm {
intro = intro,
markdown = markdown,
language = LangList.removeRegion(realLanguage | prev.language),
topics = topics ?? UblogPost.Topic.fromStrList,
topics = topics ?? UblogTopic.fromStrList,
live = live,
updated = UblogPost.Recorded(user.id, DateTime.now).some,
lived = prev.lived orElse live.option(UblogPost.Recorded(user.id, DateTime.now))

View File

@ -65,7 +65,7 @@ final class UblogPaginator(
maxPerPage = maxPerPage
)
def liveByTopic(topic: UblogPost.Topic, page: Int): Fu[Paginator[PreviewPost]] =
def liveByTopic(topic: UblogTopic, page: Int): Fu[Paginator[PreviewPost]] =
Paginator(
adapter = new Adapter[PreviewPost](
collection = colls.post,

View File

@ -14,7 +14,7 @@ case class UblogPost(
markdown: String,
language: Lang,
image: Option[PicfitImage.Id],
topics: List[UblogPost.Topic],
topics: List[UblogTopic],
live: Boolean,
created: UblogPost.Recorded,
updated: Option[UblogPost.Recorded],
@ -25,7 +25,7 @@ case class UblogPost(
def isBy(u: User) = created.by == u.id
def indexable = live && topics.exists(t => UblogPost.Topic.chessExists(t.value))
def indexable = live && topics.exists(t => UblogTopic.chessExists(t.value))
}
object UblogPost {
@ -34,46 +34,6 @@ object UblogPost {
case class Recorded(by: User.ID, at: DateTime)
case class Topic(value: String) extends StringValue {
val url = value.replace(" ", "_")
}
object Topic {
val chess = List(
"Chess",
"Analysis",
"Puzzle",
"Opening",
"Endgame",
"Tactics",
"Strategy",
"Chess engine",
"Chess bot",
"Chess Personalities",
"Over the board",
"Tournament",
"Chess960",
"Crazyhouse",
"Chess960",
"King of the Hill",
"Three-check",
"Antichess",
"Atomic",
"Horde",
"Racing Kings"
)
val all = chess ::: List(
"Software Development",
"Lichess",
"Off topic"
)
val exists = all.toSet
val chessExists = chess.toSet
def get(str: String) = exists(str) option Topic(str)
def fromStrList(str: String) = str.split(',').toList.flatMap(get).distinct
def fromUrl(str: String) = get(str.replace("_", " "))
}
case class Likes(value: Int) extends AnyVal
case class Views(value: Int) extends AnyVal
case class Rank(value: DateTime) extends AnyVal

View File

@ -38,7 +38,7 @@ final class UblogRank(colls: UblogColls)(implicit ec: ExecutionContext) {
for {
doc <- docOption
likes <- doc.getAsOpt[UblogPost.Likes]("likes")
topics <- doc.getAsOpt[List[UblogPost.Topic]]("topics")
topics <- doc.getAsOpt[List[UblogTopic]]("topics")
liveAt <- doc.getAsOpt[DateTime]("at")
tier <- doc int "tier"
} yield (topics, likes, liveAt, tier)
@ -68,7 +68,7 @@ final class UblogRank(colls: UblogColls)(implicit ec: ExecutionContext) {
lila.common.Future.applySequentially(docs) { doc =>
(
doc.string("_id"),
doc.getAsOpt[List[UblogPost.Topic]]("topics"),
doc.getAsOpt[List[UblogTopic]]("topics"),
doc.getAsOpt[UblogPost.Likes]("likes"),
doc.getAsOpt[UblogPost.Recorded]("lived")
).tupled ?? { case (id, topics, likes, lived) =>
@ -85,7 +85,7 @@ final class UblogRank(colls: UblogColls)(implicit ec: ExecutionContext) {
}
private def computeRank(
topics: List[UblogPost.Topic],
topics: List[UblogTopic],
likes: UblogPost.Likes,
liveAt: DateTime,
tier: UblogBlog.Tier
@ -94,7 +94,7 @@ final class UblogRank(colls: UblogColls)(implicit ec: ExecutionContext) {
val likeHours =
if (likes.value < 1) 0
else (5 * math.log(likes.value) + 1).toInt.atMost(likes.value) * 12
val topicsMultiplier = topics.count(t => UblogPost.Topic.chessExists(t.value)) match {
val topicsMultiplier = topics.count(t => UblogTopic.chessExists(t.value)) match {
case 0 => 0.5
case 1 => 1
case _ => 1.5

View File

@ -0,0 +1,93 @@
package lila.ublog
import reactivemongo.api.bson.BSONNull
import reactivemongo.api.ReadPreference
import scala.concurrent.duration._
import scala.concurrent.ExecutionContext
import lila.db.dsl._
import lila.memo.CacheApi
case class UblogTopic(value: String) extends StringValue {
val url = value.replace(" ", "_")
}
object UblogTopic {
val chess = List(
"Chess",
"Analysis",
"Puzzle",
"Opening",
"Endgame",
"Tactics",
"Strategy",
"Chess engine",
"Chess bot",
"Chess Personalities",
"Over the board",
"Tournament",
"Chess960",
"Crazyhouse",
"Chess960",
"King of the Hill",
"Three-check",
"Antichess",
"Atomic",
"Horde",
"Racing Kings"
)
val all = chess ::: List(
"Software Development",
"Lichess",
"Off topic"
)
val exists = all.toSet
val chessExists = chess.toSet
def get(str: String) = exists(str) option UblogTopic(str)
def fromStrList(str: String) = str.split(',').toList.flatMap(get).distinct
def fromUrl(str: String) = get(str.replace("_", " "))
case class WithPosts(topic: UblogTopic, posts: List[UblogPost.PreviewPost], nb: Int)
}
final class UblogTopicApi(colls: UblogColls, cacheApi: CacheApi)(implicit ec: ExecutionContext) {
import UblogBsonHandlers._
private val withPostsCache =
cacheApi.unit[List[UblogTopic.WithPosts]](_.refreshAfterWrite(30 seconds).buildAsyncFuture { _ =>
colls.post
.aggregateList(UblogTopic.all.size, ReadPreference.secondaryPreferred) { framework =>
import framework._
Facet(
UblogTopic.all.map { topic =>
topic -> List(
Match($doc("live" -> true, "topics" -> topic)),
Project(previewPostProjection),
Sort(Descending("rank")),
Group(BSONNull)("nb" -> SumAll, "posts" -> PushField("$ROOT")),
Project($doc("_id" -> false, "nb" -> true, "posts" -> $doc("$slice" -> $arr("$posts", 3))))
)
}
) -> 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 =>
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)
}
})
def withPosts: Fu[List[UblogTopic.WithPosts]] = withPostsCache.get {}
}

View File

@ -26,4 +26,8 @@
<string name="noPostsInThisBlogYet">No posts in this blog, yet.</string>
<string name="noDrafts">No drafts to show.</string>
<string name="latestBlogPosts">Latest blog posts</string>
<plurals name="viewAllNbPosts">
<item quantity="one">View one post</item>
<item quantity="other">View all %s posts</item>
</plurals>
</resources>

View File

@ -42,7 +42,7 @@
background-size: cover;
background-position: center;
}
transition: 0.6s all ease-in-out;
transition: 0.6s transform ease-in-out;
overflow: hidden;
transform-origin: bottom;
.ublog-post-card:hover & {
@ -69,5 +69,24 @@
margin-top: 2vmin;
display: block;
}
&--mini {
font-size: 0.9em;
line-height: 0;
> * {
line-height: 1.5em;
}
.ublog-post-card {
&__title {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 2vmin;
background: $c-bg-zebra;
opacity: 0.9;
}
}
}
}
}

View File

@ -116,4 +116,28 @@
}
}
}
&-topics {
.ublog-post-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20em, 1fr));
grid-gap: 3vmin;
}
&__topic {
margin-bottom: 3em;
h2 {
margin-bottom: 1rem;
a {
@extend %flex-between;
}
strong {
letter-spacing: 0.5ch;
font-weight: normal;
}
span {
font-size: 1rem;
}
}
}
}
}