Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import org.scalajs.linker.interface.ModuleSplitStyle

import scala.sys.process.*

lazy val projectVersion = "2.3.4"
lazy val projectVersion = "2.4.0"
lazy val organizationName = "ru.trett"
lazy val scala3Version = "3.7.4"
lazy val circeVersion = "0.14.15"
Expand Down
1 change: 1 addition & 0 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 38 additions & 37 deletions client/src/main/scala/client/Home.scala
Original file line number Diff line number Diff line change
Expand Up @@ -52,53 +52,54 @@ object Home:
case Failure(err) => handleError(err)
}

def render: Element = div(
cls := "cards main-content",
def render: Element =
div(
onMountBind(ctx =>
refreshFeedsBus --> { page =>
val response = getChannelsAndFeedsRequest(page)
val data = response.collectSuccess
val errors = response.collectFailure
data.addObserver(feedsObserver)(ctx.owner)
errors.addObserver(errorObserver)(ctx.owner)
}
),
cls := "cards main-content",
div(
onMountBind(ctx =>
markAllAsReadBus --> { _ =>
val link = feedVar.now().map(_.link)
if (link.nonEmpty) {
val response = updateFeedRequest(link)
response.addObserver(itemClickObserver)(ctx.owner)
}
refreshFeedsBus --> { page =>
val response = getChannelsAndFeedsRequest(page)
val data = response.collectSuccess
val errors = response.collectFailure
data.addObserver(feedsObserver)(ctx.owner)
errors.addObserver(errorObserver)(ctx.owner)
}
),
div(
onMountBind(ctx =>
markAllAsReadBus --> { _ =>
val link = feedVar.now().map(_.link)
if (link.nonEmpty) {
val response = updateFeedRequest(link)
response.addObserver(itemClickObserver)(ctx.owner)
}
}
)
),
div(
onMountBind(ctx =>
refreshUnreadCountBus --> { _ =>
val response = getUnreadCountRequest()
response.addObserver(unreadCountObserver)(ctx.owner)
}
)
)
),
feeds(),
div(
onMountBind(ctx =>
refreshUnreadCountBus --> { _ =>
val response = getUnreadCountRequest()
response.addObserver(unreadCountObserver)(ctx.owner)
}
display.flex,
justifyContent.center,
marginTop.px := 20,
marginBottom.px := 20,
Button(
_.design := ButtonDesign.Transparent,
_.icon := IconName.download,
"More News",
onClick.mapTo(feedVar.now().size / pageLimit + 1) --> Home.refreshFeedsBus,
hidden <-- feedVar.signal.map(xs => xs.isEmpty)
)
)
),
feeds(),
div(
display.flex,
justifyContent.center,
marginTop.px := 20,
marginBottom.px := 20,
Button(
_.design := ButtonDesign.Transparent,
_.icon := IconName.download,
"More News",
onClick.mapTo(feedVar.now().size / pageLimit + 1) --> Home.refreshFeedsBus,
hidden <-- feedVar.signal.map(xs => xs.isEmpty)
)
)
)

private def feeds(): Element =
val response = getChannelsAndFeedsRequest(1)
Expand Down
18 changes: 18 additions & 0 deletions client/src/main/scala/client/Models.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,28 @@ package client

import com.raquo.airstream.state.{StrictSignal, Var}
import ru.trett.rss.models.*
import io.circe.Decoder
import io.circe.generic.semiauto.*

type ChannelList = List[ChannelData]
type FeedItemList = List[FeedItemData]

object Decoders:
given Decoder[UserSettings] = deriveDecoder
given Decoder[SummarySuccess] = deriveDecoder
given Decoder[SummaryError] = deriveDecoder
given Decoder[SummaryResult] = Decoder.instance { cursor =>
cursor.downField("type").as[String].flatMap {
case "success" => cursor.as[SummarySuccess]
case "error" => cursor.as[SummaryError]
case other =>
Left(
io.circe.DecodingFailure(s"Unknown SummaryResult type: $other", cursor.history)
)
}
}
given Decoder[SummaryResponse] = deriveDecoder

final class Model:
val feedVar: Var[FeedItemList] = Var(List())
val channelVar: Var[ChannelList] = Var(List())
Expand Down
24 changes: 13 additions & 11 deletions client/src/main/scala/client/NavBar.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,22 @@ object NavBar {
cls := "sticky-navbar",
ShellBar(
_.primaryTitle := "RSS Reader",
_.notificationsCount <-- unreadCountSignal.map(count =>
if (count > 0) count.toString else ""
),
_.showNotifications <-- unreadCountSignal.map(_ > 0),
_.notificationsCount <-- unreadCountSignal.combineWith(settingsSignal).map {
case (count, settings) =>
val show = !settings.exists(_.isAiMode) && count > 0
if show then count.toString else ""
},
_.showNotifications <-- unreadCountSignal.combineWith(settingsSignal).map {
case (count, settings) => !settings.exists(_.isAiMode) && count > 0
},
_.slots.profile := Avatar(_.icon := IconName.customer, idAttr := profileId),
_.slots.logo := Icon(_.name := IconName.home),
_.item(
_.icon := IconName.ai,
_.text := "Summary",
onClick.mapTo(()) --> { Router.currentPageVar.set(SummaryRoute) }
),
_.events.onProfileClick.map(item => Some(item.detail.targetRef)) --> popoverBus.writer,
_.events.onLogoClick.mapTo(()) --> { Router.currentPageVar.set(HomeRoute) },
_.events.onLogoClick.mapTo(()) --> { _ =>
settingsSignal.now() match
case Some(settings) => Router.toMainPage(settings)
case None => Router.currentPageVar.set(LoginRoute)
},
_.events.onNotificationsClick.mapTo(()) --> {
EventBus.emit(Home.markAllAsReadBus -> ())
}
Expand All @@ -59,7 +62,6 @@ object NavBar {
"Update feeds",
onClick
.mapTo(())
// TODO: show loading spinner
.flatMap(_ => refreshFeedsRequest()) --> { _ =>
EventBus.emit(
Home.refreshFeedsBus -> 1,
Expand Down
9 changes: 9 additions & 0 deletions client/src/main/scala/client/NetworkUtils.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import org.scalajs.dom
import scala.util.Failure
import scala.util.Success
import scala.util.Try
import ru.trett.rss.models.UserSettings

object NetworkUtils {

Expand Down Expand Up @@ -52,6 +53,14 @@ object NetworkUtils {
.map(foreignHtmlElement)
)

import Decoders.given

def ensureSettingsLoaded(): EventStream[Try[UserSettings]] =
FetchStream
.withDecoder(responseDecoder[UserSettings])
.get("/api/user/settings")
.collect { case Success(Some(value)) => Success(value) }

def logout(): EventStream[Unit] =
FetchStream.post("/api/logout", _.body("")).mapTo(())
}
16 changes: 14 additions & 2 deletions client/src/main/scala/client/Router.scala
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package client

import be.doeraene.webcomponents.ui5.Text
import com.raquo
import com.raquo.airstream.state.Var
import com.raquo.laminar.api.L.*
import org.scalajs.dom
import ru.trett.rss.models.UserSettings
import scala.util.{Success, Failure}

@main
def createApp(): Unit =
Expand All @@ -20,7 +21,10 @@ case object NotFoundRoute extends Route

object Router:

val currentPageVar: Var[Route] = Var[Route](HomeRoute)
val currentPageVar: Var[Route] = Var[Route](LoginRoute)
def toMainPage(settings: UserSettings): Unit =
val mainPage = if settings.isAiMode then SummaryRoute else HomeRoute
currentPageVar.set(mainPage)

private def login = LoginPage.render
private def navbar = NavBar.render
Expand All @@ -29,7 +33,15 @@ object Router:
def settings: Element = SettingsPage.render
def summary: Element = SummaryPage.render

private val model = AppState.model

private val root = div(
NetworkUtils.ensureSettingsLoaded() --> {
case Success(settings) =>
model.settingsVar.set(Some(settings))
toMainPage(settings)
case Failure(err) => NetworkUtils.handleError(err)
},
child <-- currentPageVar.signal.map {
case LoginRoute => login
case HomeRoute => div(navbar, notifications, home)
Expand Down
71 changes: 62 additions & 9 deletions client/src/main/scala/client/SettingsPage.scala
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,10 @@ object SettingsPage {
case Failure(err) => handleError(err)
}

given feedItemDecoder: Decoder[FeedItemData] = deriveDecoder

given channelDecoder: Decoder[ChannelData] = deriveDecoder

given settingsDecoder: Decoder[UserSettings] = deriveDecoder

given settingsEncoder: Encoder[UserSettings] = deriveEncoder
import Decoders.given
given Decoder[FeedItemData] = deriveDecoder
given Decoder[ChannelData] = deriveDecoder
given Encoder[UserSettings] = deriveEncoder

def render: Element = div(
cls := "cards main-content",
Expand All @@ -69,8 +66,10 @@ object SettingsPage {
Link(
"Return to feeds",
_.icon := IconName.`nav-back`,
_.events.onClick.mapTo(HomeRoute) --> {
Router.currentPageVar.set
_.events.onClick.mapTo(()) --> { _ =>
settingsSignal.now() match
case Some(settings) => Router.toMainPage(settings)
case None => Router.currentPageVar.set(LoginRoute)
},
marginBottom.px := 20
),
Expand Down Expand Up @@ -117,6 +116,60 @@ object SettingsPage {
)
)
),
div(
formBlockStyle,
marginBottom.px := 16,
Label(
"App mode",
_.forId := "app-mode-cmb",
_.showColon := true,
_.wrappingType := WrappingType.None,
paddingRight.px := 20
),
Select(
_.id := "app-mode-cmb",
_.events.onChange
.map(_.detail.selectedOption.textContent) --> settingsVar
.updater[String]((a, b) =>
a.map(x => x.copy(aiMode = Some(b == "AI Mode")))
),
Select.option(
_.selected <-- settingsSignal.map(_.exists(_.isAiMode)),
"AI Mode"
),
Select.option(
_.selected <-- settingsSignal.map(_.exists(!_.isAiMode)),
"Regular Mode"
)
)
),
div(
formBlockStyle,
marginBottom.px := 16,
Label(
"AI Model",
_.forId := "summary-model-cmb",
_.showColon := true,
_.wrappingType := WrappingType.None,
paddingRight.px := 20
),
Select(
_.id := "summary-model-cmb",
_.events.onChange
.map(_.detail.selectedOption.textContent) --> settingsVar
.updater[String]((a, b) =>
a.map(x => x.copy(summaryModel = Some(b)))
),
SummaryModel.all.map(model =>
Select.option(
_.selected <-- settingsSignal.map(x =>
x.flatMap(_.summaryModel).contains(model.displayName)
),
model.displayName
)
)
)
),
div(
paddingTop.px := 10,
Button(
Expand Down
Loading