Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
java-version: '21'
cache: 'sbt'
- uses: sbt/setup-sbt@v1

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/dependency-graph.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
java-version: '21'
cache: 'sbt'
- uses: sbt/setup-sbt@v1
- uses: scalacenter/sbt-dependency-submission@v2
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
java-version: '21'
cache: 'sbt'
- uses: sbt/setup-sbt@v1

Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,4 @@ The project is a multi-module sbt project with the following key directories:

- The main branch for this project is called "main". Never push directly to main; always use feature branches and create pull requests for changes.
- Commit messages should follow the Conventional Commits specification for clarity and consistency.

1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Read the @AGENTS.md file to get more context about the project and instructions.
4 changes: 2 additions & 2 deletions 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 Expand Up @@ -74,7 +74,7 @@ lazy val server = project
organization := organizationName,
scalaVersion := scala3Version,
name := "server",
dockerBaseImage := "eclipse-temurin:17-jre-noble",
dockerBaseImage := "eclipse-temurin:21-jre-jammy",
dockerRepository := sys.env.get("REGISTRY"),
dockerExposedPorts := Seq(8080),
watchSources ++= (client / Compile / watchSources).value,
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
28 changes: 15 additions & 13 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(Some(LoginRoute))
},
_.events.onNotificationsClick.mapTo(()) --> {
EventBus.emit(Home.markAllAsReadBus -> ())
}
Expand All @@ -52,14 +55,13 @@ object NavBar {
_.item(
_.icon := IconName.settings,
"Settings",
onClick.mapTo(()) --> { Router.currentPageVar.set(SettingsRoute) }
onClick.mapTo(()) --> { Router.currentPageVar.set(Some(SettingsRoute)) }
),
_.item(
_.icon := IconName.refresh,
"Update feeds",
onClick
.mapTo(())
// TODO: show loading spinner
.flatMap(_ => refreshFeedsRequest()) --> { _ =>
EventBus.emit(
Home.refreshFeedsBus -> 1,
Expand All @@ -72,7 +74,7 @@ object NavBar {
_.icon := IconName.log,
"Sign out",
onClick.flatMap(_ => NetworkUtils.logout()) --> { _ =>
Router.currentPageVar.set(LoginRoute)
Router.currentPageVar.set(Some(LoginRoute))
}
)
)
Expand Down
16 changes: 15 additions & 1 deletion 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 All @@ -36,7 +37,7 @@ object NetworkUtils {
)

def handleError(ex: Throwable): Unit = ex.getMessage match
case "Unauthorized" | "Session expired" => Router.currentPageVar.set(LoginRoute)
case "Unauthorized" | "Session expired" => Router.currentPageVar.set(Some(LoginRoute))
case _ => errorMessage(ex)

AirstreamError.registerUnhandledErrorCallback(err => errorMessage(err))
Expand All @@ -52,6 +53,19 @@ object NetworkUtils {
.map(foreignHtmlElement)
)

import Decoders.given

def ensureSettingsLoaded(): EventStream[Try[UserSettings]] =
FetchStream
.withDecoder(responseDecoder[UserSettings])
.get("/api/user/settings")
.map {
case Success(Some(value)) => Success(value)
case Success(None) =>
Failure(new RuntimeException("Failed to parse settings response"))
case Failure(err) => Failure(err)
}

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

import be.doeraene.webcomponents.ui5.Text
import com.raquo
import be.doeraene.webcomponents.ui5.{Text, BusyIndicator}
import be.doeraene.webcomponents.ui5.configkeys.BusyIndicatorSize
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 +22,10 @@ case object NotFoundRoute extends Route

object Router:

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

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

private val model = AppState.model

private lazy val loadingComponent = div(
display.flex,
flexDirection.column,
alignItems.center,
justifyContent.center,
minHeight := "100vh",
BusyIndicator(_.active := true, _.size := BusyIndicatorSize.L),
div(
marginTop.px := 20,
color := "var(--sapContent_LabelColor)",
fontSize := "var(--sapFontSize)",
"Loading application..."
)
)

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)
case SettingsRoute => div(navbar, notifications, settings)
case SummaryRoute => div(navbar, notifications, summary)
case ErrorRoute => div(Text("An error occured"))
case NotFoundRoute => div(Text("Not Found"))
case None => loadingComponent
case Some(LoginRoute) => login
case Some(HomeRoute) => div(navbar, notifications, home)
case Some(SettingsRoute) => div(navbar, notifications, settings)
case Some(SummaryRoute) => div(navbar, notifications, summary)
case Some(ErrorRoute) => div(Text("An error occured"))
case Some(NotFoundRoute) => div(Text("Not Found"))
},
className := "app-container"
)
Expand Down
Loading