diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 50d40164..d0f3c156 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,7 +1,6 @@ name: CI on: pull_request: - push: jobs: test: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 54b4064a..a29deb9f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ target/ project/metals.sbt project/project/metals.sbt project/project/project/metals.sbt + +harness-web-app-template/res/js +harness-web-app-template/res/css diff --git a/build.sbt b/build.sbt index b295e964..956ce63c 100644 --- a/build.sbt +++ b/build.sbt @@ -2,7 +2,13 @@ // =====| Shared Settings |===== -val Scala_3 = "3.1.2-RC3" +enablePlugins(GitVersioning) +git.gitTagToVersionNumber := { tag => + if (tag.matches("^\\d+\\..*$")) Some(tag) + else None +} + +val Scala_3 = "3.1.3" val MyOrg = "io.github.kalin-rudnicki" @@ -48,6 +54,7 @@ lazy val `harness-root` = `harness-sql`, `harness-web`.js, `harness-web`.jvm, + `harness-web-app-template`, ) lazy val `harness-test` = @@ -114,7 +121,7 @@ lazy val `harness-sql` = testSettings, libraryDependencies ++= Seq( "dev.zio" %%% "zio" % "2.0.0", - "dev.zio" %%% "zio-json" % "0.3.0-RC10", + "dev.zio" %%% "zio-json" % "0.3.0", "org.typelevel" %%% "cats-core" % "2.8.0", "org.typelevel" %% "shapeless3-deriving" % "3.0.1", "org.postgresql" % "postgresql" % "42.5.0" % Test, @@ -140,3 +147,115 @@ lazy val `harness-web` = ), ) .dependsOn(`harness-zio` % "test->test;compile->compile") + +// =====| |===== + +lazy val `harness-web-app-template` = + project + .in(file("harness-web-app-template")) + .aggregate( + `harness-web-app-template--model`.jvm, + `harness-web-app-template--model`.js, + `harness-web-app-template--api`, + `harness-web-app-template--ui-web`, + ) + +lazy val `harness-web-app-template--model` = + crossProject(JSPlatform, JVMPlatform) + .in(file("harness-web-app-template/model")) + .settings( + name := "harness-web-app-template--model", + publish / skip := true, + miscSettings, + testSettings, + ) + .dependsOn(`harness-web`) + +lazy val `harness-web-app-template--db-model` = + project + .in(file("harness-web-app-template/db-model")) + .settings( + name := "harness-web-app-template--db-model", + publish / skip := true, + miscSettings, + testSettings, + libraryDependencies ++= Seq( + "org.postgresql" % "postgresql" % "42.5.0", + ), + ) + .dependsOn(`harness-sql`) + +lazy val `harness-web-app-template--api` = + project + .in(file("harness-web-app-template/api")) + .settings( + name := "harness-web-app-template--api", + publish / skip := true, + miscSettings, + testSettings, + libraryDependencies ++= Seq( + "org.mindrot" % "jbcrypt" % "0.4", + ), + ) + .dependsOn(`harness-web-app-template--model`.jvm, `harness-web-app-template--db-model`) + +lazy val buildUI: InputKey[Unit] = inputKey("build UI") + +lazy val `harness-web-app-template--ui-web` = + project + .in(file("harness-web-app-template/ui-web")) + .enablePlugins(ScalaJSPlugin) + .settings( + name := "harness-web-app-template--ui-web", + publish / skip := true, + miscSettings, + testSettings, + scalaJSUseMainModuleInitializer := true, + buildUI := + Def.inputTaskDyn { + import complete.DefaultParsers._ + + val resDir = file("/home/kalin/dev/current/harness/harness-web-app-template/res") + + lazy val fast = (fastLinkJS, "fastopt") + lazy val full = (fullLinkJS, "opt") + + val args: List[String] = spaceDelimited("").parsed.toList + val (t, s) = + if (args.contains("-F")) full + else fast + val m = !args.contains("-m") + + Def.sequential( + Def.inputTask { println("Running 'webComp'...") }.toTask(""), + Compile / t, + Def + .inputTask { + def jsFile(fName: String): File = { + val crossTargetDir = (crossTarget in (Compile / t)).value + val projectName = normalizedName.value + file(s"$crossTargetDir/$projectName-$s/$fName") + } + + val moveToDir = resDir / "js" + + val files = + jsFile("main.js") :: + (if (m) jsFile("main.js.map") :: Nil else Nil) + + moveToDir.mkdirs() + moveToDir.listFiles.foreach { f => + if (f.name.contains("main.js")) + f.delete() + } + files.foreach { f => + IO.copyFile(f, new File(moveToDir, f.getName)) + } + + () + } + .toTask(""), + ) + }.evaluated, + ) + .dependsOn(`harness-web-app-template--model`.js, `harness-web`.js) diff --git a/harness-web-app-template/api/src/main/scala/template/api/Main.scala b/harness-web-app-template/api/src/main/scala/template/api/Main.scala new file mode 100644 index 00000000..f3b3baab --- /dev/null +++ b/harness-web-app-template/api/src/main/scala/template/api/Main.scala @@ -0,0 +1,31 @@ +package template.api + +import harness.core.* +import harness.sql.* +import harness.sql.autoSchema.* +import harness.web.* +import harness.web.server.{given, *} +import harness.zio.* +import template.api.routes as R +import zio.* + +object Main extends ExecutableApp { + + override val executable: Executable = + Executable + .withParser(ServerConfig.parser) + .withLayer { + ZLayer.succeed(ConnectionFactory("jdbc:postgresql:postgres", "kalin", "psql-pass")) + } + .withEffectNel { config => + PostgresMeta + .schemaDiff(Tables(db.model.User.tableSchema, db.model.Session.tableSchema)) + .mapErrorToNel(HError.SystemFailure("Failed to execute schema diff", _)) *> + Server.start(config) { + Route.stdRoot(config)( + R.User.routes, + ) + } + } + +} diff --git a/harness-web-app-template/api/src/main/scala/template/api/db/queries/Session.scala b/harness-web-app-template/api/src/main/scala/template/api/db/queries/Session.scala new file mode 100644 index 00000000..016ee585 --- /dev/null +++ b/harness-web-app-template/api/src/main/scala/template/api/db/queries/Session.scala @@ -0,0 +1,17 @@ +package template.api.db.queries + +import harness.sql.* +import harness.sql.query.{given, *} +import template.api.db.model as M + +object Session extends TableQueries[M.Session.Id, M.Session] { + + val fromSessionToken: QueryIO[String, M.Session.Identity] = + Prepare.selectIO { Input[String] } { token => + Select + .from[M.Session]("s") + .where { s => s.token === token } + .returning { s => s } + } + +} diff --git a/harness-web-app-template/api/src/main/scala/template/api/db/queries/User.scala b/harness-web-app-template/api/src/main/scala/template/api/db/queries/User.scala new file mode 100644 index 00000000..8fded87b --- /dev/null +++ b/harness-web-app-template/api/src/main/scala/template/api/db/queries/User.scala @@ -0,0 +1,29 @@ +package template.api.db.queries + +import harness.sql.* +import harness.sql.query.{given, *} +import template.api.db.model as M + +object User extends TableQueries[M.User.Id, M.User] { + + val fromSessionToken: QueryIO[String, M.User.Identity] = + Prepare.selectIO { Input[String] } { token => + Select + .from[M.Session]("s") + .join[M.User]("u") + .on { case (s, u) => s.userId === u.id } + .where { case (s, _) => s.token === token } + .returning { case (_, u) => u } + } + + val byUsername: QueryIO[String, M.User.Identity] = + Prepare + .selectIO { Input[String] } { username => + Select + .from[M.User]("u") + .where { u => u.lowerUsername === username } + .returning { u => u } + } + .cmap[String](_.toLowerCase) + +} diff --git a/harness-web-app-template/api/src/main/scala/template/api/routes/Helpers.scala b/harness-web-app-template/api/src/main/scala/template/api/routes/Helpers.scala new file mode 100644 index 00000000..dcf0ea76 --- /dev/null +++ b/harness-web-app-template/api/src/main/scala/template/api/routes/Helpers.scala @@ -0,0 +1,26 @@ +package template.api.routes + +import harness.core.* +import harness.sql.* +import harness.web.server.* +import harness.zio.* +import template.api.db.{model as M, queries as Q} +import zio.* + +private[routes] object Helpers { + + // TODO (KR) : name this on a project specific basis + val SessionToken: String = "Template-Session-Token" + + val userFromSession: HRION[ConnectionFactory & HttpRequest, M.User.Identity] = + HttpRequest.cookie.get[String](Helpers.SessionToken).flatMap { tok => + Q.User.fromSessionToken(tok).single.mapErrorToNel(HError.UserError("error getting user session", _)) + } + + val userFromSessionOptional: HRION[ConnectionFactory & HttpRequest, Option[M.User.Identity]] = + HttpRequest.cookie.find[String](Helpers.SessionToken).flatMap { + case Some(tok) => Q.User.fromSessionToken(tok).single.asSome.mapErrorToNel(HError.UserError("error getting user session", _)) + case None => ZIO.none + } + +} diff --git a/harness-web-app-template/api/src/main/scala/template/api/routes/User.scala b/harness-web-app-template/api/src/main/scala/template/api/routes/User.scala new file mode 100644 index 00000000..1d9a8b46 --- /dev/null +++ b/harness-web-app-template/api/src/main/scala/template/api/routes/User.scala @@ -0,0 +1,57 @@ +package template.api.routes + +import harness.core.* +import harness.sql.* +import harness.web.* +import harness.web.server.{given, *} +import harness.zio.* +import org.mindrot.jbcrypt.BCrypt +import template.api.db.{model as M, queries as Q} +import template.model as D +import zio.* + +object User { + + val routes: Route[ConnectionFactory] = + "user" /: Route.oneOf( + (HttpMethod.GET / "from-session-token").implement { _ => + for { + dbUser <- Helpers.userFromSession + user = D.user.User(dbUser.firstName, dbUser.lastName, dbUser.username, dbUser.email) + } yield HttpResponse.encodeJson(user) + }, + (HttpMethod.GET / "from-session-token-optional").implement { _ => + for { + dbUser <- Helpers.userFromSessionOptional + user = dbUser.map { dbUser => D.user.User(dbUser.firstName, dbUser.lastName, dbUser.username, dbUser.email) } + } yield HttpResponse.encodeJson(user) + }, + (HttpMethod.POST / "login").implement { _ => + for { + body <- HttpRequest.jsonBody[D.user.Login] + user <- Q.User.byUsername(body.username).single.mapErrorToNel(HError.InternalDefect("user by username", _)) + _ <- ZIO.failNel(HError.UserError("Invalid Password")).unless(BCrypt.checkpw(body.password, user.encryptedPassword)) + session = M.Session.newForUser(user) + _ <- Q.Session.insert(session).mapErrorToNel(HError.InternalDefect("create session", _)) + } yield HttpResponse("OK").withCookie(Cookie(Helpers.SessionToken, session.token).rootPath.secure) + }, + (HttpMethod.POST / "log-out").implement { _ => + for { + tok <- HttpRequest.cookie.get[String](Helpers.SessionToken) + dbSession <- Q.Session.fromSessionToken(tok).single.mapErrorToNel(HError.UserError("error getting user session", _)) + _ <- Q.Session.deleteById(dbSession.id).single.mapErrorToNel(HError.InternalDefect("delete session", _)) + } yield HttpResponse("OK").withCookie(Cookie.unset(Helpers.SessionToken).rootPath.secure) + }, + (HttpMethod.POST / "sign-up").implement { _ => + for { + body <- HttpRequest.jsonBody[D.user.SignUp] + encryptedPassword = BCrypt.hashpw(body.password, BCrypt.gensalt) + user = new M.User.Identity(M.User.Id.gen, body.firstName, body.lastName, body.username, body.username.toLowerCase, encryptedPassword, body.email) + session = M.Session.newForUser(user) + _ <- Q.User.insert(user).mapErrorToNel(HError.InternalDefect("create user", _)) + _ <- Q.Session.insert(session).mapErrorToNel(HError.InternalDefect("create session", _)) + } yield HttpResponse("OK").withCookie(Cookie(Helpers.SessionToken, session.token).rootPath.secure) + }, + ) + +} diff --git a/harness-web-app-template/db-model/src/main/scala/template/api/db/model/Tables.scala b/harness-web-app-template/db-model/src/main/scala/template/api/db/model/Tables.scala new file mode 100644 index 00000000..6169bfed --- /dev/null +++ b/harness-web-app-template/db-model/src/main/scala/template/api/db/model/Tables.scala @@ -0,0 +1,55 @@ +package template.api.db.model + +import harness.sql.* +import java.util.UUID + +final case class User[F[_]]( + id: F[User.Id], + firstName: F[String], + lastName: F[String], + username: F[String], + lowerUsername: F[String], + encryptedPassword: F[String], + email: F[String], +) extends Table.WithId[F, User.Id] +object User extends Table.Companion.WithId[User] { + + override implicit lazy val tableSchema: TableSchema[User] = + TableSchema.derived[User]("user") { + new User.Cols( + id = User.Id.col("id").primaryKey, + firstName = Col.string("first_name"), + lastName = Col.string("last_name"), + username = Col.string("username"), + lowerUsername = Col.string("lower_username"), + encryptedPassword = Col.string("encrypted_password"), + email = Col.string("email"), + ) + } + +} + +final case class Session[F[_]]( + id: F[Session.Id], + userId: F[User.Id], + token: F[String], +) extends Table.WithId[F, Session.Id] +object Session extends Table.Companion.WithId[Session] { + + override implicit lazy val tableSchema: TableSchema[Session] = + TableSchema.derived[Session]("session") { + new Session.Cols( + id = Session.Id.col("id").primaryKey, + userId = User.Id.col("user_id").references(ForeignKeyRef("user", "id")), + token = Col.string("token"), + ) + } + + def newForUser(user: User.Identity): Session.Identity = + new Session.Identity( + id = Session.Id.gen, + userId = user.id, + token = s"${UUID.randomUUID}:${UUID.randomUUID}", + ) + +} diff --git a/harness-web-app-template/model/shared/src/main/scala/template/model/user/Login.scala b/harness-web-app-template/model/shared/src/main/scala/template/model/user/Login.scala new file mode 100644 index 00000000..ad887b69 --- /dev/null +++ b/harness-web-app-template/model/shared/src/main/scala/template/model/user/Login.scala @@ -0,0 +1,11 @@ +package template.model.user + +import zio.json.* + +final case class Login( + username: String, + password: String, +) +object Login { + implicit val jsonCodec: JsonCodec[Login] = DeriveJsonCodec.gen +} diff --git a/harness-web-app-template/model/shared/src/main/scala/template/model/user/SignUp.scala b/harness-web-app-template/model/shared/src/main/scala/template/model/user/SignUp.scala new file mode 100644 index 00000000..69a199b0 --- /dev/null +++ b/harness-web-app-template/model/shared/src/main/scala/template/model/user/SignUp.scala @@ -0,0 +1,14 @@ +package template.model.user + +import zio.json.* + +final case class SignUp( + firstName: String, + lastName: String, + username: String, + password: String, + email: String, +) +object SignUp { + implicit val jsonCodec: JsonCodec[SignUp] = DeriveJsonCodec.gen +} diff --git a/harness-web-app-template/model/shared/src/main/scala/template/model/user/User.scala b/harness-web-app-template/model/shared/src/main/scala/template/model/user/User.scala new file mode 100644 index 00000000..b0a4cdaa --- /dev/null +++ b/harness-web-app-template/model/shared/src/main/scala/template/model/user/User.scala @@ -0,0 +1,13 @@ +package template.model.user + +import zio.json.* + +final case class User( + firstName: String, + lastName: String, + username: String, + email: String, +) +object User { + implicit val jsonCodec: JsonCodec[User] = DeriveJsonCodec.gen +} diff --git a/harness-web-app-template/res/favicon.ico b/harness-web-app-template/res/favicon.ico new file mode 100644 index 00000000..d5f7429a Binary files /dev/null and b/harness-web-app-template/res/favicon.ico differ diff --git a/harness-web-app-template/res/index.html b/harness-web-app-template/res/index.html new file mode 100644 index 00000000..227c41be --- /dev/null +++ b/harness-web-app-template/res/index.html @@ -0,0 +1,15 @@ + + + + + + Title + + + + + + + + + \ No newline at end of file diff --git a/harness-web-app-template/res/scss/colors.scss b/harness-web-app-template/res/scss/colors.scss new file mode 100644 index 00000000..ea127f16 --- /dev/null +++ b/harness-web-app-template/res/scss/colors.scss @@ -0,0 +1,22 @@ +// --- Colors --- + +$color-primary: #20A9FE; +$color-primary-accent: #1d8ace; +$color-primary-light: #85D0FE; +$color-primary-dark: #003F66; +$color-on-primary: #000000; + +$color-secondary: #00B887; +$color-secondary-accent: #05926d; +$color-secondary-light: #35FD88; +$color-secondary-dark: #028D3C; +$color-on-secondary: #000000; + +$color-background: #2E3738; +$color-on-background: #E8F7EE; + +$color-surface: #1C2122; +$color-on-surface: #E8F7EE; + +$color-error: #800E13; +$color-on-error: #000000; \ No newline at end of file diff --git a/harness-web-app-template/res/scss/helpers.scss b/harness-web-app-template/res/scss/helpers.scss new file mode 100644 index 00000000..aac09756 --- /dev/null +++ b/harness-web-app-template/res/scss/helpers.scss @@ -0,0 +1,76 @@ +@import "colors"; + +// =====| Basic Backgrounds |===== + +%bg-primary { + background-color: $color-primary; + color: $color-on-primary; +} + +%bg-primary-accent { + background-color: $color-primary-accent; + color: $color-on-primary; +} + +%bg-primary-light { + background-color: $color-primary-light; + color: $color-on-primary; +} + +%bg-primary-dark { + background-color: $color-primary-dark; + color: $color-on-primary; +} + +%bg-secondary { + background-color: $color-secondary; + color: $color-on-secondary; +} + +%bg-secondary-accent { + background-color: $color-secondary-accent; + color: $color-on-secondary; +} + +%bg-secondary-light { + background-color: $color-secondary-light; + color: $color-on-secondary; +} + +%bg-secondary-dark { + background-color: $color-secondary-dark; + color: $color-on-secondary; +} + +%bg-background { + background-color: $color-background; + color: $color-on-background; +} + +%bg-surface { + background-color: $color-surface; + color: $color-on-surface; +} + +%bg-error { + background-color: $color-error; + color: $color-on-error; +} + +// =====| Mixins / Extends |===== + +%clickable { + cursor: pointer; + user-select: none; +} + +%pb { + @extend %bg-primary; + @extend %clickable; + border: 2px solid black; + padding: 5px 10px; + + &:hover { + background-color: $color-primary-accent; + } +} \ No newline at end of file diff --git a/harness-web-app-template/res/scss/styles.scss b/harness-web-app-template/res/scss/styles.scss new file mode 100644 index 00000000..00c91947 --- /dev/null +++ b/harness-web-app-template/res/scss/styles.scss @@ -0,0 +1,84 @@ +@import "helpers"; + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + + +.nav-bar { + @extend %bg-primary; + display: flex; + flex-wrap: nowrap; + height: 2rem; + + &__section { + height: 100%; + + &--wrap { + flex: 0 1 auto; + } + + &--expand { + flex: 1 0 auto; + } + } + + &__item { + @extend %clickable; + display: inline-flex; + justify-content: center; + align-items: center; + height: 100%; + padding: 0 1rem; + + &:hover { + background-color: $color-primary-accent; + } + } +} + + +.form-field { + padding: 0.5rem; + + &__label { + margin-left: 0.5rem; + display: block; + font-weight: 500; + font-size: 1.25rem; + } + + &__input { + display: block; + padding: 0.2rem 0.5rem; + border-radius: 0.75rem; + border: 3px solid #333; + outline: unset; + background-color: #f5f5f5; + font-family: monospace; + font-weight: 600; + width: 30ch; + } +} + + +.form-submit { + @extend %pb; + border-radius: 0.5rem; + margin-left: 2rem; +} + + +.page { + @extend %bg-background; + padding: 2rem 5%; + height: calc(100vh - 2rem); + + h1 { + color: $color-secondary; + margin-left: 2rem; + margin-bottom: 1rem; + } +} \ No newline at end of file diff --git a/harness-web-app-template/ui-web/src/main/scala/template/ui/web/Main.scala b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/Main.scala new file mode 100644 index 00000000..af13ae8f --- /dev/null +++ b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/Main.scala @@ -0,0 +1,18 @@ +package template.ui.web + +import harness.web.client.* +import harness.zio.RunMode + +object Main extends PageApp { + + override protected val runMode: RunMode = RunMode.Dev + + override val routeMatcher: RouteMatcher.Root = + "page" /: RouteMatcher.root( + RouteMatcher.const { pages.Index.page }, + "home" /: RouteMatcher.const { pages.Home.page }, + "login" /: RouteMatcher.const { pages.Login.page }, + "sign-up" /: RouteMatcher.const { pages.SignUp.page }, + ) + +} diff --git a/harness-web-app-template/ui-web/src/main/scala/template/ui/web/helpers/Api.scala b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/helpers/Api.scala new file mode 100644 index 00000000..1a33ea81 --- /dev/null +++ b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/helpers/Api.scala @@ -0,0 +1,43 @@ +package template.ui.web.helpers + +import harness.web.client.* +import harness.zio.* +import template.model as D + +object Api { + + object user { + + def fromSessionToken: HTaskN[D.user.User] = + HttpRequest + .get("/api/user/from-session-token") + .noBody + .jsonResponse[D.user.User] + + def fromSessionTokenOptional: HTaskN[Option[D.user.User]] = + HttpRequest + .get("/api/user/from-session-token-optional") + .noBody + .jsonResponse[Option[D.user.User]] + + def signUp(d: D.user.SignUp): HTaskN[Unit] = + HttpRequest + .post("/api/user/sign-up") + .jsonBody(d) + .unit200 + + def login(d: D.user.Login): HTaskN[Unit] = + HttpRequest + .post("/api/user/login") + .jsonBody(d) + .unit200 + + def logOut: HTaskN[Unit] = + HttpRequest + .post("/api/user/log-out") + .noBody + .unit200 + + } + +} diff --git a/harness-web-app-template/ui-web/src/main/scala/template/ui/web/helpers/Widgets.scala b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/helpers/Widgets.scala new file mode 100644 index 00000000..0e93c580 --- /dev/null +++ b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/helpers/Widgets.scala @@ -0,0 +1,72 @@ +package template.ui.web.helpers + +import _root_.template.model as D +import harness.core.* +import harness.web.client.* +import harness.web.client.vdom.{given, *} +import harness.web.client.widgets.* + +object Widgets { + + val signedOutNavBar: CModifier = + NavBar( + NavBar.linkItem(Url("page")(), "Index"), + )( + NavBar.linkItem(Url("page", "sign-up")(), "Sign Up"), + NavBar.linkItem(Url("page", "login")(), "Login"), + ) + + val signedInNavBar: Modifier[D.user.User] = + NavBar( + NavBar.linkItem(Url("page")(), "Index"), + )( + NavBar.linkItem(Url("page", "home")(), "Home"), + PModifier.builder.withState[D.user.User] { s => + NavBar.linkItem(Url("page", "account")(), s.firstName) + }, + NavBar.item( + "Log Out", + PModifier.builder.withRaise { rh => + onClick := { _ => + rh.raiseZIO( + Api.user.logOut.as(Raise.History.push(Url("page", "login")())), + ) + } + }, + ), + ) + + val optNavBar: Modifier[Option[D.user.User]] = + SumWidget.option(signedInNavBar, signedOutNavBar).unit + + def stdInput[V: StringDecoder]( + _label: String, + _id: String, + inputModifier: CModifier = PModifier(), + labelModifier: CModifier = PModifier(), + ): ModifierAV[Submit, String, V] = + div(CssClass.b("form-field")).defer( + formInput[V] + .apply( + CssClass.be("form-field", "input"), + id := _id, + inputModifier, + ) + .required + .labeled( + _label, + label( + _, + CssClass.be("form-field", "label"), + `for` := _id, + labelModifier, + ), + ), + ) + + val stdSubmit: CNodeWidgetA[Submit] = + formSubmitButton( + CssClass.b("form-submit"), + ) + +} diff --git a/harness-web-app-template/ui-web/src/main/scala/template/ui/web/pages/Home.scala b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/pages/Home.scala new file mode 100644 index 00000000..677457ab --- /dev/null +++ b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/pages/Home.scala @@ -0,0 +1,36 @@ +package template.ui.web.pages + +import _root_.template.model as D +import _root_.template.ui.web.helpers.* +import cats.syntax.either.* +import harness.web.client.* +import harness.web.client.vdom.{given, *} + +object Home { + + final case class Env( + user: D.user.User, + ) + + val page: Page = + Page.builder + .fetchStateOrRedirect { + Api.user.fromSessionTokenOptional.map { + case Some(user) => Env(user).asRight + case None => Url("page", "login")().asLeft + } + } + .constTitle("Home") + .body { + PModifier( + Widgets.signedInNavBar.zoomOut[Env](_.user), + div( + CssClass.b("page"), + h1("Home"), + PModifier.builder.withState[D.user.User] { u => p(s"Welcome, ${u.firstName}") }.zoomOut[Env](_.user), + ), + ) + } + .logA + +} diff --git a/harness-web-app-template/ui-web/src/main/scala/template/ui/web/pages/Index.scala b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/pages/Index.scala new file mode 100644 index 00000000..bcc5e563 --- /dev/null +++ b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/pages/Index.scala @@ -0,0 +1,25 @@ +package template.ui.web.pages + +import _root_.template.ui.web.helpers.* +import harness.web.client.* +import harness.web.client.vdom.{given, *} + +object Index { + + val page: Page = + Page.builder + .fetchState { Api.user.fromSessionTokenOptional } + .constTitle("Index") + .body { + PModifier( + Widgets.optNavBar, + div( + CssClass.b("page"), + h1("Template"), + p("Hopefully, an easy startup..."), + ), + ) + } + .logA + +} diff --git a/harness-web-app-template/ui-web/src/main/scala/template/ui/web/pages/Login.scala b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/pages/Login.scala new file mode 100644 index 00000000..4e16b6a3 --- /dev/null +++ b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/pages/Login.scala @@ -0,0 +1,45 @@ +package template.ui.web.pages + +import _root_.template.model as D +import _root_.template.ui.web.helpers.* +import cats.data.EitherNel +import cats.syntax.either.* +import harness.web.client.* +import harness.web.client.vdom.{given, *} +import harness.web.client.widgets.* +import harness.zio.* +import zio.json.* + +object Login { + + val page: Page = + Page.builder + .fetchStateOrRedirect[D.user.Login] { + Api.user.fromSessionTokenOptional.map { + case Some(_) => Url("page", "home")().asLeft + case None => D.user.Login("", "").asRight + } + } + .constTitle("Login") + .body { + PModifier( + Widgets.signedOutNavBar, + div( + CssClass.b("page"), + h1("Login"), + ( + Widgets.stdInput[String]("Username:", "username").zoomOut[D.user.Login](_.username) <*> + Widgets.stdInput[String]("Password:", "password", inputModifier = `type`.password).zoomOut[D.user.Login](_.password) <*> + Widgets.stdSubmit("Login") + ).mapValue(D.user.Login.apply) + .mapActionV { (login, _) => + Api.user + .login(login) + .as(Raise.History.push(Url("page", "home")()) :: Nil) + }, + ), + ) + } + .logA + +} diff --git a/harness-web-app-template/ui-web/src/main/scala/template/ui/web/pages/SignUp.scala b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/pages/SignUp.scala new file mode 100644 index 00000000..d03c6aac --- /dev/null +++ b/harness-web-app-template/ui-web/src/main/scala/template/ui/web/pages/SignUp.scala @@ -0,0 +1,76 @@ +package template.ui.web.pages + +import _root_.template.model as D +import _root_.template.ui.web.helpers.* +import cats.data.EitherNel +import cats.syntax.either.* +import harness.web.client.* +import harness.web.client.vdom.{given, *} +import harness.web.client.widgets.* +import harness.zio.* +import zio.json.* + +object SignUp { + + final case class Env( + firstName: String, + lastName: String, + username: String, + passwords: Env.Passwords, + email: String, + ) + object Env { + + final case class Passwords( + password: String, + confirmPassword: String, + ) { + def validate: EitherNel[String, String] = + if (password == confirmPassword) password.asRight + else "Passwords do not match".leftNel + } + object Passwords { + implicit val codec: JsonCodec[Env.Passwords] = DeriveJsonCodec.gen + } + + implicit val codec: JsonCodec[Env] = DeriveJsonCodec.gen + + } + + val page: Page = + Page.builder + .fetchStateOrRedirect[Env] { + Api.user.fromSessionTokenOptional.map { + case Some(_) => Url("page", "home")().asLeft + case None => Env("", "", "", Env.Passwords("", ""), "").asRight + } + } + .constTitle("Sign Up") + .body { + PModifier( + Widgets.signedOutNavBar, + div( + CssClass.b("page"), + h1("Sign Up"), + ( + Widgets.stdInput[String]("First Name:", "first-name").zoomOut[Env](_.firstName) <*> + Widgets.stdInput[String]("Last Name:", "last-name").zoomOut[Env](_.lastName) <*> + Widgets.stdInput[String]("Username:", "username").zoomOut[Env](_.username) <*> + ( + Widgets.stdInput[String]("Password:", "password", inputModifier = `type`.password).zoomOut[Env.Passwords](_.password) <*> + Widgets.stdInput[String]("Confirm Password:", "confirm-password", inputModifier = `type`.password).zoomOut[Env.Passwords](_.confirmPassword) + ).zoomOut[Env](_.passwords).flatMapValue(Env.Passwords(_, _).validate) <*> + Widgets.stdInput[String]("Email:", "email", inputModifier = `type`.email).zoomOut[Env](_.email) <*> + Widgets.stdSubmit("Sign Up") + ).mapValue(D.user.SignUp.apply) + .mapActionV { (signUp, _) => + Api.user + .signUp(signUp) + .as(Raise.History.push(Url("page", "home")()) :: Nil) + }, + ), + ) + } + .logA + +} diff --git a/project/plugins.sbt b/project/plugins.sbt index 7c12cb03..4b011aff 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -2,5 +2,6 @@ addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.10.1") addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0") +addSbtPlugin("com.github.sbt" % "sbt-git" % "2.0.0") addDependencyTreePlugin