diff --git a/build.sbt b/build.sbt index 634bf88..8111cf1 100644 --- a/build.sbt +++ b/build.sbt @@ -198,8 +198,13 @@ lazy val unidocs = project // To avoid including this in the core build lazy val examples = project .in(file("examples")) + .enablePlugins( + KropTwirlLayout + ) .settings( commonSettings, + // To avoid warn "unused import" for krop/examples/htmx/views/*.scala.html files + scalacOptions += "-Wconf:src=.*/views/html/.*:silent", moduleName := "krop-examples", mimaPreviousArtifacts := Set.empty, // This sets Krop into development mode, which gives useful tools for diff --git a/docs/src/pages/directory.conf b/docs/src/pages/directory.conf index a0d2a47..3eccfb5 100644 --- a/docs/src/pages/directory.conf +++ b/docs/src/pages/directory.conf @@ -9,4 +9,5 @@ laika.navigationOrder = [ controller tools.md development.md + examples ] diff --git a/docs/src/pages/examples/README.md b/docs/src/pages/examples/README.md new file mode 100644 index 0000000..ad3fd66 --- /dev/null +++ b/docs/src/pages/examples/README.md @@ -0,0 +1,6 @@ +# Examples + +The following examples demonstrate Krop in action, from a simple application to more advanced patterns. +They illustrate how Krop's design enables you to build everything from fast and scalable JSON APIs +to applications that serve static files, handle form data, compose routes, and use middleware for cross-cutting concerns like authorization. +These practical examples are here to help you see Krop's philosophy in practice and to serve as a starting point for your own projects. diff --git a/docs/src/pages/examples/directory.conf b/docs/src/pages/examples/directory.conf new file mode 100644 index 0000000..ccbe0a2 --- /dev/null +++ b/docs/src/pages/examples/directory.conf @@ -0,0 +1,4 @@ +laika.navigationOrder = [ + README.md + htmx.md +] \ No newline at end of file diff --git a/docs/src/pages/examples/htmx.md b/docs/src/pages/examples/htmx.md new file mode 100644 index 0000000..057a559 --- /dev/null +++ b/docs/src/pages/examples/htmx.md @@ -0,0 +1,751 @@ +# Htmx: Building an Authorization Application + +This example demonstrates how to build a complete authorization application using Krop with [HTMX][htmx] for dynamic interactions. +The application implements user registration, login, logout, and session management — +all while following Krop's principles of type safety and composability. +It also showcases a task management interface with tabbed navigation as an example of authenticated content. + +## Overview + +The application provides a full user authentication flow: + +- **Login page** – Users can sign in with their credentials + ![Start page](images/login.png) +- **Registration page** – New users can create an account + ![Registration page](images/registration.png) +- **Authenticated dashboard** – After login, users see a personalized welcome page with task management tabs + ![After logging](images/after_logging.png) +- **Session management** – User tokens are stored in cookies for persistent sessions +- **Secure logout** – Users can safely end their session + +## Code Structure + +The example is organized into several logical components that work together seamlessly: + +### Models + +The `LoginRequest` case class defines the data structure for authentication requests, +using Circe's derivation support for JSON encoding and decoding. +This model is shared across the registration and login flows. + +```scala +package krop.examples.htmx.models + +import io.circe.* +import krop.route.FormCodec + +final case class LoginRequest( + username: String, + password: String +) derives Decoder, + Encoder, + FormCodec +``` + +### Routes + +The `Routes` object defines all the application's endpoints using Krop's routing DSL. Each route specifies: + +- The HTTP method (GET, POST) +- The URL path pattern +- Request extraction (headers, body parsing) +- Response handling with appropriate status codes + +The routes support: + +- Static pages (`/`, `/home`, `/register`) +- Authentication actions (`/auth/login`, `/auth/logout`) +- User creation (`/new_user`) +- Static asset serving (`/asset/*`) + +```scala +package krop.examples.htmx.routes + +import krop.all.* +import krop.examples.htmx.models.LoginRequest +import org.http4s.Status as HttpStatus +import org.http4s.headers.* + +object Routes: + val index = + Route( + Request.get(Path.root).extractHeader[`Cookie`], + Response.ok(Entity.html) + ) + + val home = + Route( + Request.get(Path.root / "home").extractHeader[`Cookie`], + Response.ok(Entity.html) + ) + + val register = + Route( + Request.get(Path.root / "register"), + Response.ok(Entity.html) + ) + + val login = Route( + Request + .post(Path.root / "auth" / "login") + .withEntity(Entity.formOf[LoginRequest]), + Response + .ok(Entity.html) + .orElse(Response.status(HttpStatus.Ok, Entity.html)) + .orNotFound + ) + + val newUser = Route( + Request + .post(Path.root / "new_user") + .withEntity(Entity.formOf[LoginRequest]), + Response + .status(HttpStatus.Created, Entity.html) + .orElse(Response.status(HttpStatus.Ok, Entity.html)) + .orNotFound + ) + + val logout = Route( + Request + .post(Path.root / "auth" / "logout") + .extractHeader[Authorization], + Response + .ok(Entity.html) + .orElse(Response.status(HttpStatus.Ok, Entity.html)) + .orNotFound + ) + + val assetRoute = + Route( + Request.get(Path.root / "asset" / Params.separatedString("/")), + Response.staticResource("/asset/") + ) +end Routes +``` + +### Views + +The application uses Twirl templates for server-side rendering. The view layer is organized as: + +#### base.scala.html + +**`base.scala.html`** – The main layout template that includes the HTMX script, common styles, and navigation structure. +It uses `hx-get` and `hx-target` attributes to enable HTMX's dynamic page updates without full reloads. + +```html +@(title: String, content: Html) + + + + + + @title + + + + + + +
+
+ @title +
+
+
+ +
+ @content +
+ + +``` + +#### login.scala.html + +**`login.scala.html`** – The login form with username and password fields. +Error messages are displayed conditionally, and the form uses `hx-post` handler to submit via fetch. + +```html +@(errorMessage: Option[String]) +
+

Login

+ +
+
+ + +
+
+ + +
+ +
+ +
+ @errorMessage.map { msg => +
@msg
+ } +
+ +

+ Don't have an account? + Register +

+
+``` + +#### register.scala.html + +**`register.scala.html`** – The registration form, similar to the login page but for new user creation. + +```html +@(errorMessage: Option[String]) +
+

Registration

+ +
+
+ + +
+
+ + +
+ +
+ +
+ @errorMessage.map { msg => +
@msg
+ } +
+ +

+ Have an account? + Login +

+
+``` + +#### welcome.scala.html + +**`welcome.scala.html`** – The authenticated user dashboard. This template: + +- Displays the user's name and logout button +- Implements three tabbed sections: "In progress", "Planned", and "Knowledge base" +- Shows example tasks in each tab +- Includes `saveUserCookies` script to extract and store the session token + +```html +@(username: String, token: String) +
+ +
+
+
+

Welcome, @username!

+
+ +
+ +
+
+ + + +
+ +
+ ... // Mocks +
+ +
+ ... // Mocks +
+ +
+ ... // Mocks +
+
+ +
+
+
+``` + +### Assets + +The JavaScript file manages client-side interactions: + +- **Cookie management** – Functions to set, get, and delete cookies for storing the authentication token +- **Tab switching** – `switchTab()` manages the task tabs on the dashboard +- **HTMX integration** – Automatically attaches the authentication token to all HTMX requests via the `htmx:beforeRequest` event listener + +```javascript +document.addEventListener('htmx:beforeRequest', function(event) { + var token = getCookie('token'); + if (token) { + event.detail.xhr.setRequestHeader('Authorization', 'Bearer ' + token); + } +}); + +function setCookie(name, value, days) { + days = days || 7; + var expires = new Date(); + expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); + document.cookie = name + "=" + encodeURIComponent(value) + + "; expires=" + expires.toUTCString() + + "; path=/"; +} + +function getCookie(name) { + var nameEQ = name + "="; + var ca = document.cookie.split(';'); + for(var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) === ' ') c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length, c.length)); + } + return null; +} + +function deleteCookie(name) { + document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; +} + +function saveUserCookies(token) { + if (token) { + setCookie('token', token); + } +} + +function clearUserCookies() { + deleteCookie('token'); +} + +function switchTab(tabId) { + document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active')); + + document.querySelector(`.tab-btn[data-tab="${tabId}"]`).classList.add('active'); + document.getElementById(`tab-${tabId}`).classList.add('active'); +} +``` + +### Server + +The `SimpleAuthService` trait defines the authentication contract with three operations: + +- `findUser(token)` – Retrieves user information from a stored token +- `login(username, password)` – Validates credentials and returns user info +- `newUser(username, password)` – Creates a new user account + +The in-memory implementation demonstrates how to store and query user data, using a `Ref[IO, Vector[UserInfo]]` +for thread-safe state management. +This is a simplified demonstration; in production, you would replace this with a proper database. + +```scala +package krop.examples.htmx.server + +import cats.effect.IO +import cats.effect.Ref +import krop.examples.htmx.server.SimpleAuthService.UserInfo + +import java.util.UUID + +/** !!!Just for the demonstration!!! */ +trait SimpleAuthService[F[_]]: + def findUser(token: String): F[Option[UserInfo]] + + def login(username: String, password: String): F[Option[UserInfo]] + + def newUser(username: String, password: String): F[Either[String, UserInfo]] + +object SimpleAuthService: + final case class UserInfo(username: String, password: String, token: String) + + def make(db: Ref[IO, Vector[UserInfo]]): SimpleAuthService[IO] = + new SimpleAuthService: + def findUser(token: String): IO[Option[UserInfo]] = + db.get.map(_.find(_.token == token)) + + def login(username: String, password: String): IO[Option[UserInfo]] = + db.get.map( + _.find(user => user.username == username && user.password == password) + ) + + def newUser( + username: String, + password: String + ): IO[Either[String, UserInfo]] = { + val newUser = UserInfo(username, password, UUID.randomUUID().toString) + + db.get.flatMap: + case users if users.exists(_.username == username) => + IO.pure(Left("A user with such username already exists.")) + case users => + db.update(users => newUser +: users).as(Right(newUser)) + } +``` + +### Handlers + +Each route has a corresponding handler that processes requests and generates responses: + +- **`InitialHandler`** – Handles the root path (`/`), checking for existing sessions and either showing the login page or redirecting to the dashboard +- **`HomeHandler`** – Serves the home endpoint, similarly checking for valid sessions +- **`RegisterHandler`** – Serves the registration form +- **`LoginHandler`** – Processes login credentials, returning either the welcome page or an error message +- **`LogoutHandler`** – Validates the token and logs out the user +- **`NewUserHandler`** – Processes registration requests, handling both success and conflict cases + +All handlers use Krop's `handleIO` method for effectful computations, with proper error handling and logging. + +#### Create Parser + +```scala +package krop.examples.htmx.handlers + +import org.http4s.headers.Cookie + +object Parser: + extension (cookie: Cookie) + def getToken: Option[String] = + cookie.values.collectFirst: + case rq if rq.name == "token" => rq.content +``` + +#### InitialHandler + +```scala +package krop.examples.htmx.handlers + +import cats.effect.IO +import cats.syntax.all.* +import krop.all.* +import krop.examples.htmx.handlers.Parser.* +import krop.examples.htmx.routes.Routes +import krop.examples.htmx.server.SimpleAuthService +import krop.examples.htmx.views.html +import org.http4s.headers.Cookie +import org.typelevel.log4cats.Logger + +final case class InitialHandler( + service: SimpleAuthService[IO] +)(using Logger[IO]): + private val name = "Personal Development Plan" + + private val defaultPage = + html.base(name, html.login(None)).toString + + val handler: Handler = + Routes.index.handleIO: (cookie: Cookie) => + cookie.getToken match + case Some(token) => + service + .findUser(token) + .map: + case Some(user) => + html + .base(name, html.welcome(user.username, token)) + .toString + case None => + defaultPage + .recoverWith: + case ex => + Logger[IO] + .error(ex)(s"Server error: ${ex.getMessage}") + .as(defaultPage) + case None => + defaultPage.pure[IO] +end InitialHandler +``` + +#### HomeHandler + +```scala +package krop.examples.htmx.handlers + +import cats.effect.IO +import cats.syntax.all.* +import krop.all.* +import krop.examples.htmx.handlers.Parser.* +import krop.examples.htmx.routes.Routes +import krop.examples.htmx.server.SimpleAuthService +import krop.examples.htmx.views.html +import org.http4s.headers.Cookie +import org.typelevel.log4cats.Logger + +final case class HomeHandler( + service: SimpleAuthService[IO] +)(using Logger[IO]): + private val defaultPage = html.login(None).toString + + val handler: Handler = + Routes.home.handleIO: (cookie: Cookie) => + cookie.getToken match + case Some(token) => + service + .findUser(token) + .map: + case Some(user) => + html.welcome(user.username, token).toString + case None => + defaultPage + .recoverWith: + case ex => + Logger[IO] + .error(ex)(s"Server error: ${ex.getMessage}") + .as(defaultPage) + case None => + defaultPage.pure[IO] +end HomeHandler +``` + +#### RegisterHandler + +```scala +package krop.examples.htmx.handlers + +import krop.all.* +import krop.examples.htmx.routes.Routes +import krop.examples.htmx.views.html + +object RegisterHandler: + val handler: Handler = + Routes.register.handle { () => + html.register(None).toString + } +``` + +#### LoginHandler + +```scala +package krop.examples.htmx.handlers + +import cats.effect.IO +import cats.syntax.all.* +import krop.all.* +import krop.examples.htmx.models.LoginRequest +import krop.examples.htmx.routes.Routes +import krop.examples.htmx.server.SimpleAuthService +import krop.examples.htmx.views.html +import org.typelevel.log4cats.Logger + +final case class LoginHandler( + service: SimpleAuthService[IO] +)(using Logger[IO]): + val handler: Handler = + Routes.login.handleIO { (request: LoginRequest) => + service + .login(request.username, request.password) + .map: + case Some(user) => + html.welcome(user.username, user.token).toString.asRight.some + case None => + html.login("User not found".some).toString.asLeft.some + .recoverWith: + case ex => + Logger[IO].error(ex)(s"Server error: ${ex.getMessage}").as(none) + } +end LoginHandler +``` + +#### LogoutHandler + +```scala +package krop.examples.htmx.handlers + +import cats.effect.IO +import cats.syntax.all.* +import krop.all.* +import krop.examples.htmx.routes.Routes +import krop.examples.htmx.server.SimpleAuthService +import krop.examples.htmx.views.html +import org.http4s.AuthScheme +import org.http4s.Credentials.Token +import org.http4s.headers.Authorization +import org.typelevel.log4cats.Logger + +final case class LogoutHandler( + service: SimpleAuthService[IO] +)(using Logger[IO]): + val handler: Handler = + Routes.logout.handleIO: (authorization: Authorization) => + authorization match + case Authorization(Token(AuthScheme.Bearer, token)) => + service + .findUser(token) + .map: + case Some(_) => + html.login(none).toString.asRight.some + case None => + html.login("User not found".some).toString.asLeft.some + case _ => + html + .login("An authorization error occurred".some) + .toString + .asLeft + .some + .pure[IO] +end LogoutHandler +``` + +#### NewUserHandler + +```scala +package krop.examples.htmx.handlers + +import cats.effect.IO +import cats.syntax.all.* +import krop.all.* +import krop.examples.htmx.models.LoginRequest +import krop.examples.htmx.routes.Routes +import krop.examples.htmx.server.SimpleAuthService +import krop.examples.htmx.views.html +import org.typelevel.log4cats.Logger + +final case class NewUserHandler( + service: SimpleAuthService[IO] +)(using Logger[IO]): + val handler: Handler = + Routes.newUser.handleIO { (request: LoginRequest) => + service + .newUser(request.username, request.password) + .map: + case Right(user) => + html.welcome(user.username, user.token).toString.asRight.some + case Left(error) => + html.register(error.some).toString.asLeft.some + .recoverWith: + case ex => + Logger[IO].error(ex)(s"Server error: ${ex.getMessage}").as(none) + } +end NewUserHandler +``` + +### Main + +The `Main` object is the application entry point. It: + +1. Creates a logger instance +2. Initializes the in-memory user database +3. Composes all routes together using `orElse` +4. Builds and runs the server on the default port + +```scala +package krop.examples.htmx + +import cats.effect.* +import krop.all.* +import krop.examples.htmx.handlers.* +import krop.examples.htmx.server.SimpleAuthService +import krop.examples.htmx.server.SimpleAuthService.UserInfo +import org.typelevel.log4cats.Logger +import org.typelevel.log4cats.slf4j.Slf4jLogger + +val name = "Personal Development Plan" + +object Main extends IOApp: + given Logger[IO] = Slf4jLogger.getLogger[IO] + + def application(db: Ref[IO, Vector[UserInfo]]): Application = + val service: SimpleAuthService[IO] = + SimpleAuthService.make(db) + + val initialHandler = InitialHandler(service) + val homeHandler = HomeHandler(service) + val loginHandler = LoginHandler(service) + val logoutHandler = LogoutHandler(service) + val newUserHandler = NewUserHandler(service) + val assetRoute = + Route( + Request.get(Path.root / "asset" / Params.separatedString("/")), + Response.staticResource("/asset/") + ) + + initialHandler.handler + .orElse(homeHandler.handler) + .orElse(RegisterHandler.handler) + .orElse(loginHandler.handler) + .orElse(logoutHandler.handler) + .orElse(newUserHandler.handler) + .orElse(assetRoute.passthrough) + .orElse(Application.notFound) + + override def run(args: List[String]): IO[ExitCode] = + Ref[IO] + .of(Vector.empty[UserInfo]) + .flatMap: db => + ServerBuilder.default + .withApplication(application(db)) + .build + .toIO + .as(ExitCode.Success) +``` + +## Using the Application + +1. Start the application by running the `Main` class +2. Open your browser and navigate to `http://localhost:8080/` +3. You'll see the login page. Use the "Register" link to create a new account +4. After registration or login, you'll be redirected to your personalized dashboard +5. Explore the task tabs, use the logout button, or switch between accounts + +## Conclusion + +This example demonstrates how Krop's design enables you to build complete, type-safe web applications +with modern frontend interactions using [HTMX][htmx]. +By combining Krop's routing, request handling, and response composition with HTMX's dynamic capabilities, +you can create rich user experiences while maintaining clean separation of concerns and functional programming principles. + +The full source code is available in the Krop [examples directory][source]. + +[htmx]: https://htmx.org/ +[source]: https://github.com/creativescala/krop/tree/main/examples/src/main diff --git a/docs/src/pages/examples/images/after_logging.png b/docs/src/pages/examples/images/after_logging.png new file mode 100644 index 0000000..e99ff95 Binary files /dev/null and b/docs/src/pages/examples/images/after_logging.png differ diff --git a/docs/src/pages/examples/images/login.png b/docs/src/pages/examples/images/login.png new file mode 100644 index 0000000..1fafbcc Binary files /dev/null and b/docs/src/pages/examples/images/login.png differ diff --git a/docs/src/pages/examples/images/registration.png b/docs/src/pages/examples/images/registration.png new file mode 100644 index 0000000..48d7ac5 Binary files /dev/null and b/docs/src/pages/examples/images/registration.png differ diff --git a/examples/src/main/resources/asset/htmx-example.css b/examples/src/main/resources/asset/htmx-example.css new file mode 100644 index 0000000..b7542ce --- /dev/null +++ b/examples/src/main/resources/asset/htmx-example.css @@ -0,0 +1,417 @@ +/* ========== Base styles ========== */ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + margin: 0; + padding: 0; + background: #f8f9fa; + color: #333; + min-height: 100vh; +} + +/* ========== Top header ========== */ +.app-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + background: white; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.header-container { + max-width: 100%; + margin: 0 auto; + padding: 16px 20px; + text-align: center; +} + +.header-title { + color: #2c3e50; + text-decoration: none; + font-size: 20px; + font-weight: 600; + letter-spacing: 0.5px; + transition: color 0.3s; + display: inline-block; +} + +.header-title:hover { + color: #3498db; +} + +.header-divider { + height: 2px; + background: linear-gradient( + to right, + transparent 0%, + #e0e0e0 10%, + #e0e0e0 90%, + transparent 100% + ); + margin: 0 20px; +} + +/* ========== Main content ========== */ +.main-content { + max-width: 100%; + margin: 80px 0 30px; + padding: 0 20px; +} + +/* ========== Forms ========== */ +.app-container { + background: white; + padding: 30px; + border-radius: 12px; + border: 1px solid #e0e0e0; + box-shadow: 0 2px 8px rgba(0,0,0,0.06); + max-width: 100%; +} + +.app-container-narrow { + background: white; + padding: 30px; + border-radius: 12px; + border: 1px solid #e0e0e0; + box-shadow: 0 2px 8px rgba(0,0,0,0.06); + max-width: 500px; + margin: 0 auto; +} + +.form-group { + margin-bottom: 20px; +} + +label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: #555; +} + +input { + width: 100%; + padding: 10px; + border: 2px solid #e0e0e0; + border-radius: 8px; + font-size: 16px; + transition: border-color 0.3s; + box-sizing: border-box; + background: white; +} + +input:focus { + outline: none; + border-color: #007bff; +} + +/* ========== Buttons ========== */ +button { + padding: 12px 24px; + cursor: pointer; + background: #007bff; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + transition: background 0.3s, transform 0.1s; +} + +button:hover { + background: #0056b3; +} + +button:active { + transform: scale(0.98); +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +#logoutBtn { + background: #dc3545; + margin-top: 15px; +} + +#logoutBtn:hover { + background: #c82333; +} + +/* ========== Messages ========== */ +.success { + color: #155724; + background: #d4edda; + padding: 12px 16px; + border-radius: 8px; + border: 1px solid #c3e6cb; + margin: 10px 0; +} + +.error { + color: #721c24; + background: #f8d7da; + padding: 12px 16px; + border-radius: 8px; + border: 1px solid #f5c6cb; + margin: 10px 0; +} + +.hidden { + display: none !important; +} + +/* ========== Blocks ========== */ +#welcomeBlock { + margin-top: 20px; +} + +#messageBlock { + margin-top: 15px; +} + +/* ========== Links ========== */ +a { + color: #007bff; + text-decoration: none; + transition: color 0.2s, text-decoration 0.2s; +} + +a:hover { + color: #0056b3; + text-decoration: underline; +} + +a:active { + color: #003d80; +} + +a:visited { + color: #6c3483; +} + +.header-title { + color: #2c3e50; + text-decoration: none; +} + +.header-title:hover { + color: #3498db; + text-decoration: none; +} + +/* ========== Top ========== */ + +.welcome-container { + width: 100%; + max-width: 100%; + margin: 0 auto; +} + +.welcome-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 2px solid #e8ecf0; +} + +.welcome-user { + flex: 1; +} + +.welcome-user h2 { + margin: 0; + color: #2c3e50; + font-size: 22px; +} + +.logout-btn { + background: #dc3545; + color: white; + border: none; + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, transform 0.1s; + flex-shrink: 0; + margin: 0; +} + +.logout-btn:hover { + background: #c82333; +} + +.logout-btn:active { + transform: scale(0.97); +} + +/* ========== Tabs ========== */ +.tabs-container { + background: #f8f9fa; + border-radius: 12px; + padding: 20px; + border: 1px solid #e8ecf0; +} + +.tabs-header { + display: flex; + gap: 4px; + margin-bottom: 20px; + border-bottom: 2px solid #e8ecf0; + padding-bottom: 12px; +} + +.tab-btn { + padding: 10px 24px; + border: none; + background: transparent; + font-size: 15px; + font-weight: 500; + color: #7f8c8d; + cursor: pointer; + border-radius: 8px 8px 0 0; + transition: all 0.2s; + position: relative; +} + +.tab-btn:hover { + color: #2c3e50; + background: #e8ecf0; +} + +.tab-btn.active { + color: #2c3e50; + background: white; + box-shadow: 0 -2px 4px rgba(0,0,0,0.04); +} + +.tab-btn.active::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + right: 0; + height: 3px; + background: #3498db; + border-radius: 2px; +} + +/* ========== Content ========== */ +.tab-content { + display: none; + animation: fadeIn 0.25s ease; +} + +.tab-content.active { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.tab-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 16px; +} + +.create-task-btn { + background: #3498db; + color: white; + border: none; + padding: 8px 18px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + display: flex; + align-items: center; + gap: 6px; +} + +.create-task-btn:hover { + background: #2980b9; +} + +.tasks-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; + max-height: 400px; + overflow-y: auto; + padding-right: 4px; +} + +.tasks-grid::-webkit-scrollbar { + width: 6px; +} + +.tasks-grid::-webkit-scrollbar-track { + background: #f0f0f0; + border-radius: 3px; +} + +.tasks-grid::-webkit-scrollbar-thumb { + background: #c0c0c0; + border-radius: 3px; +} + +.tasks-grid::-webkit-scrollbar-thumb:hover { + background: #a0a0a0; +} + +.task-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px; + background: white; + border-radius: 10px; + border: 1px solid #e8ecf0; + transition: transform 0.15s, box-shadow 0.15s; +} + +.task-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.06); +} + +.task-icon { + font-size: 24px; + flex-shrink: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: #f0f4f8; + border-radius: 8px; +} + +.task-info { + flex: 1; + min-width: 0; +} + +.task-title { + font-weight: 500; + color: #2c3e50; + margin-bottom: 4px; + font-size: 15px; +} + +.task-meta { + font-size: 13px; + color: #95a5a6; + line-height: 1.4; +} diff --git a/examples/src/main/resources/asset/htmx-example.js b/examples/src/main/resources/asset/htmx-example.js new file mode 100644 index 0000000..a5bf4b0 --- /dev/null +++ b/examples/src/main/resources/asset/htmx-example.js @@ -0,0 +1,48 @@ +document.addEventListener('htmx:beforeRequest', function(event) { + var token = getCookie('token'); + if (token) { + event.detail.xhr.setRequestHeader('Authorization', 'Bearer ' + token); + } +}); + +function setCookie(name, value, days) { + days = days || 7; + var expires = new Date(); + expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); + document.cookie = name + "=" + encodeURIComponent(value) + + "; expires=" + expires.toUTCString() + + "; path=/"; +} + +function getCookie(name) { + var nameEQ = name + "="; + var ca = document.cookie.split(';'); + for(var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) === ' ') c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length, c.length)); + } + return null; +} + +function deleteCookie(name) { + document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; +} + +function saveUserCookies(token) { + if (token) { + setCookie('token', token); + } +} + +function clearUserCookies() { + deleteCookie('token'); +} + +function switchTab(tabId) { + document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active')); + + document.querySelector(`.tab-btn[data-tab="${tabId}"]`).classList.add('active'); + document.getElementById(`tab-${tabId}`).classList.add('active'); +} diff --git a/examples/src/main/scala/krop/examples/htmx/Main.scala b/examples/src/main/scala/krop/examples/htmx/Main.scala new file mode 100644 index 0000000..ad17014 --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/Main.scala @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package krop.examples.htmx + +import cats.effect.* +import krop.all.* +import krop.examples.htmx.handlers.* +import krop.examples.htmx.server.SimpleAuthService +import krop.examples.htmx.server.SimpleAuthService.UserInfo +import org.typelevel.log4cats.Logger +import org.typelevel.log4cats.slf4j.Slf4jLogger + +val name = "Personal Development Plan" + +object Main extends IOApp: + given Logger[IO] = Slf4jLogger.getLogger[IO] + + def application(db: Ref[IO, Vector[UserInfo]]): Application = + val service: SimpleAuthService[IO] = + SimpleAuthService.make(db) + + val initialHandler = InitialHandler(service) + val homeHandler = HomeHandler(service) + val loginHandler = LoginHandler(service) + val logoutHandler = LogoutHandler(service) + val newUserHandler = NewUserHandler(service) + val assetRoute = + Route( + Request.get(Path.root / "asset" / Params.separatedString("/")), + Response.staticResource("/asset/") + ) + + initialHandler.handler + .orElse(homeHandler.handler) + .orElse(RegisterHandler.handler) + .orElse(loginHandler.handler) + .orElse(logoutHandler.handler) + .orElse(newUserHandler.handler) + .orElse(assetRoute.passthrough) + .orElse(Application.notFound) + + override def run(args: List[String]): IO[ExitCode] = + Ref[IO] + .of(Vector.empty[UserInfo]) + .flatMap: db => + ServerBuilder.default + .withApplication(application(db)) + .build + .toIO + .as(ExitCode.Success) diff --git a/examples/src/main/scala/krop/examples/htmx/handlers/HomeHandler.scala b/examples/src/main/scala/krop/examples/htmx/handlers/HomeHandler.scala new file mode 100644 index 0000000..d1ba2df --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/handlers/HomeHandler.scala @@ -0,0 +1,52 @@ +/* + * Copyright 2023 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package krop.examples.htmx.handlers + +import cats.effect.IO +import cats.syntax.all.* +import krop.all.* +import krop.examples.htmx.handlers.Parser.* +import krop.examples.htmx.routes.Routes +import krop.examples.htmx.server.SimpleAuthService +import krop.examples.htmx.views.html +import org.http4s.headers.Cookie +import org.typelevel.log4cats.Logger + +final case class HomeHandler( + service: SimpleAuthService[IO] +)(using Logger[IO]): + private val defaultPage = html.login(None).toString + + val handler: Handler = + Routes.home.handleIO: (cookie: Cookie) => + cookie.getToken match + case Some(token) => + service + .findUser(token) + .map: + case Some(user) => + html.welcome(user.username, token).toString + case None => + defaultPage + .recoverWith: + case ex => + Logger[IO] + .error(ex)(s"Server error: ${ex.getMessage}") + .as(defaultPage) + case None => + defaultPage.pure[IO] +end HomeHandler diff --git a/examples/src/main/scala/krop/examples/htmx/handlers/InitialHandler.scala b/examples/src/main/scala/krop/examples/htmx/handlers/InitialHandler.scala new file mode 100644 index 0000000..9df4630 --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/handlers/InitialHandler.scala @@ -0,0 +1,57 @@ +/* + * Copyright 2023 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package krop.examples.htmx.handlers + +import cats.effect.IO +import cats.syntax.all.* +import krop.all.* +import krop.examples.htmx.handlers.Parser.* +import krop.examples.htmx.routes.Routes +import krop.examples.htmx.server.SimpleAuthService +import krop.examples.htmx.views.html +import org.http4s.headers.Cookie +import org.typelevel.log4cats.Logger + +final case class InitialHandler( + service: SimpleAuthService[IO] +)(using Logger[IO]): + private val name = "Personal Development Plan" + + private val defaultPage = + html.base(name, html.login(None)).toString + + val handler: Handler = + Routes.index.handleIO: (cookie: Cookie) => + cookie.getToken match + case Some(token) => + service + .findUser(token) + .map: + case Some(user) => + html + .base(name, html.welcome(user.username, token)) + .toString + case None => + defaultPage + .recoverWith: + case ex => + Logger[IO] + .error(ex)(s"Server error: ${ex.getMessage}") + .as(defaultPage) + case None => + defaultPage.pure[IO] +end InitialHandler diff --git a/examples/src/main/scala/krop/examples/htmx/handlers/LoginHandler.scala b/examples/src/main/scala/krop/examples/htmx/handlers/LoginHandler.scala new file mode 100644 index 0000000..094b5ea --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/handlers/LoginHandler.scala @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package krop.examples.htmx.handlers + +import cats.effect.IO +import cats.syntax.all.* +import krop.all.* +import krop.examples.htmx.models.LoginRequest +import krop.examples.htmx.routes.Routes +import krop.examples.htmx.server.SimpleAuthService +import krop.examples.htmx.views.html +import org.typelevel.log4cats.Logger + +final case class LoginHandler( + service: SimpleAuthService[IO] +)(using Logger[IO]): + val handler: Handler = + Routes.login.handleIO { (request: LoginRequest) => + service + .login(request.username, request.password) + .map: + case Some(user) => + html.welcome(user.username, user.token).toString.asRight.some + case None => + html.login("User not found".some).toString.asLeft.some + .recoverWith: + case ex => + Logger[IO].error(ex)(s"Server error: ${ex.getMessage}").as(none) + } +end LoginHandler diff --git a/examples/src/main/scala/krop/examples/htmx/handlers/LogoutHandler.scala b/examples/src/main/scala/krop/examples/htmx/handlers/LogoutHandler.scala new file mode 100644 index 0000000..fc4ce43 --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/handlers/LogoutHandler.scala @@ -0,0 +1,48 @@ +/* + * Copyright 2023 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package krop.examples.htmx.handlers + +import cats.effect.IO +import cats.syntax.all.* +import krop.all.* +import krop.examples.htmx.routes.Routes +import krop.examples.htmx.server.SimpleAuthService +import krop.examples.htmx.views.html +import org.http4s.AuthScheme +import org.http4s.Credentials.Token +import org.http4s.headers.Authorization + +final case class LogoutHandler(service: SimpleAuthService[IO]): + val handler: Handler = + Routes.logout.handleIO: (authorization: Authorization) => + authorization match + case Authorization(Token(AuthScheme.Bearer, token)) => + service + .findUser(token) + .map: + case Some(_) => + html.login(none).toString.asRight.some + case None => + html.login("User not found".some).toString.asLeft.some + case _ => + html + .login("An authorization error occurred".some) + .toString + .asLeft + .some + .pure[IO] +end LogoutHandler diff --git a/examples/src/main/scala/krop/examples/htmx/handlers/NewUserHandler.scala b/examples/src/main/scala/krop/examples/htmx/handlers/NewUserHandler.scala new file mode 100644 index 0000000..34a7c60 --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/handlers/NewUserHandler.scala @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package krop.examples.htmx.handlers + +import cats.effect.IO +import cats.syntax.all.* +import krop.all.* +import krop.examples.htmx.models.LoginRequest +import krop.examples.htmx.routes.Routes +import krop.examples.htmx.server.SimpleAuthService +import krop.examples.htmx.views.html +import org.typelevel.log4cats.Logger + +final case class NewUserHandler( + service: SimpleAuthService[IO] +)(using Logger[IO]): + val handler: Handler = + Routes.newUser.handleIO { (request: LoginRequest) => + service + .newUser(request.username, request.password) + .map: + case Right(user) => + html.welcome(user.username, user.token).toString.asRight.some + case Left(error) => + html.register(error.some).toString.asLeft.some + .recoverWith: + case ex => + Logger[IO].error(ex)(s"Server error: ${ex.getMessage}").as(none) + } +end NewUserHandler diff --git a/examples/src/main/scala/krop/examples/htmx/handlers/Parser.scala b/examples/src/main/scala/krop/examples/htmx/handlers/Parser.scala new file mode 100644 index 0000000..03924d0 --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/handlers/Parser.scala @@ -0,0 +1,25 @@ +/* + * Copyright 2023 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package krop.examples.htmx.handlers + +import org.http4s.headers.Cookie + +object Parser: + extension (cookie: Cookie) + def getToken: Option[String] = + cookie.values.collectFirst: + case rq if rq.name == "token" => rq.content diff --git a/examples/src/main/scala/krop/examples/htmx/handlers/RegisterHandler.scala b/examples/src/main/scala/krop/examples/htmx/handlers/RegisterHandler.scala new file mode 100644 index 0000000..4de2ed1 --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/handlers/RegisterHandler.scala @@ -0,0 +1,27 @@ +/* + * Copyright 2023 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package krop.examples.htmx.handlers + +import krop.all.* +import krop.examples.htmx.routes.Routes +import krop.examples.htmx.views.html + +object RegisterHandler: + val handler: Handler = + Routes.register.handle { () => + html.register(None).toString + } diff --git a/examples/src/main/scala/krop/examples/htmx/models/LoginRequest.scala b/examples/src/main/scala/krop/examples/htmx/models/LoginRequest.scala new file mode 100644 index 0000000..36eaf5c --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/models/LoginRequest.scala @@ -0,0 +1,27 @@ +/* + * Copyright 2023 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package krop.examples.htmx.models + +import io.circe.* +import krop.route.FormCodec + +final case class LoginRequest( + username: String, + password: String +) derives Decoder, + Encoder, + FormCodec diff --git a/examples/src/main/scala/krop/examples/htmx/routes/Routes.scala b/examples/src/main/scala/krop/examples/htmx/routes/Routes.scala new file mode 100644 index 0000000..2a495e6 --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/routes/Routes.scala @@ -0,0 +1,78 @@ +/* + * Copyright 2023 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package krop.examples.htmx.routes + +import krop.all.* +import krop.examples.htmx.models.LoginRequest +import org.http4s.Status as HttpStatus +import org.http4s.headers.* + +object Routes: + val index = + Route( + Request.get(Path.root).extractHeader[`Cookie`], + Response.ok(Entity.html) + ) + + val home = + Route( + Request.get(Path.root / "home").extractHeader[`Cookie`], + Response.ok(Entity.html) + ) + + val register = + Route( + Request.get(Path.root / "register"), + Response.ok(Entity.html) + ) + + val login = Route( + Request + .post(Path.root / "auth" / "login") + .withEntity(Entity.formOf[LoginRequest]), + Response + .ok(Entity.html) + .orElse(Response.status(HttpStatus.Ok, Entity.html)) + .orNotFound + ) + + val newUser = Route( + Request + .post(Path.root / "new_user") + .withEntity(Entity.formOf[LoginRequest]), + Response + .status(HttpStatus.Created, Entity.html) + .orElse(Response.status(HttpStatus.Ok, Entity.html)) + .orNotFound + ) + + val logout = Route( + Request + .post(Path.root / "auth" / "logout") + .extractHeader[Authorization], + Response + .ok(Entity.html) + .orElse(Response.status(HttpStatus.Ok, Entity.html)) + .orNotFound + ) + + val assetRoute = + Route( + Request.get(Path.root / "asset" / Params.separatedString("/")), + Response.staticResource("/asset/") + ) +end Routes diff --git a/examples/src/main/scala/krop/examples/htmx/server/SimpleAuthService.scala b/examples/src/main/scala/krop/examples/htmx/server/SimpleAuthService.scala new file mode 100644 index 0000000..e5152aa --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/server/SimpleAuthService.scala @@ -0,0 +1,57 @@ +/* + * Copyright 2023 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package krop.examples.htmx.server + +import cats.effect.IO +import cats.effect.Ref +import krop.examples.htmx.server.SimpleAuthService.UserInfo + +import java.util.UUID + +/** !!!Just for the demonstration!!! */ +trait SimpleAuthService[F[_]]: + def findUser(token: String): F[Option[UserInfo]] + + def login(username: String, password: String): F[Option[UserInfo]] + + def newUser(username: String, password: String): F[Either[String, UserInfo]] + +object SimpleAuthService: + final case class UserInfo(username: String, password: String, token: String) + + def make(db: Ref[IO, Vector[UserInfo]]): SimpleAuthService[IO] = + new SimpleAuthService: + def findUser(token: String): IO[Option[UserInfo]] = + db.get.map(_.find(_.token == token)) + + def login(username: String, password: String): IO[Option[UserInfo]] = + db.get.map( + _.find(user => user.username == username && user.password == password) + ) + + def newUser( + username: String, + password: String + ): IO[Either[String, UserInfo]] = { + val newUser = UserInfo(username, password, UUID.randomUUID().toString) + + db.get.flatMap: + case users if users.exists(_.username == username) => + IO.pure(Left("A user with such username already exists.")) + case users => + db.update(users => newUser +: users).as(Right(newUser)) + } diff --git a/examples/src/main/scala/krop/examples/htmx/views/base.scala.html b/examples/src/main/scala/krop/examples/htmx/views/base.scala.html new file mode 100644 index 0000000..83021e0 --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/views/base.scala.html @@ -0,0 +1,29 @@ +@(title: String, content: Html) + + + + + + @title + + + + + + +
+
+ @title +
+
+
+ +
+ @content +
+ + \ No newline at end of file diff --git a/examples/src/main/scala/krop/examples/htmx/views/login.scala.html b/examples/src/main/scala/krop/examples/htmx/views/login.scala.html new file mode 100644 index 0000000..22cf06b --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/views/login.scala.html @@ -0,0 +1,38 @@ +@(errorMessage: Option[String]) +
+

Login

+ +
+
+ + +
+
+ + +
+ +
+ +
+ @errorMessage.map { msg => +
@msg
+ } +
+ +

+ Don't have an account? + Register +

+
\ No newline at end of file diff --git a/examples/src/main/scala/krop/examples/htmx/views/register.scala.html b/examples/src/main/scala/krop/examples/htmx/views/register.scala.html new file mode 100644 index 0000000..5083585 --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/views/register.scala.html @@ -0,0 +1,38 @@ +@(errorMessage: Option[String]) +
+

Registration

+ +
+
+ + +
+
+ + +
+ +
+ +
+ @errorMessage.map { msg => +
@msg
+ } +
+ +

+ Have an account? + Login +

+
\ No newline at end of file diff --git a/examples/src/main/scala/krop/examples/htmx/views/welcome.scala.html b/examples/src/main/scala/krop/examples/htmx/views/welcome.scala.html new file mode 100644 index 0000000..596a276 --- /dev/null +++ b/examples/src/main/scala/krop/examples/htmx/views/welcome.scala.html @@ -0,0 +1,150 @@ +@(username: String, token: String) +
+ +
+
+
+

Welcome, @username!

+
+ +
+ +
+
+ + + +
+ +
+
+ +
+
+
+
📚
+
+
Read "Creative Scala"
+
Noel Welsh • Progress: 60%
+
+
+
+
📝
+
+
Write an article about Krop
+
Blog • Deadline: July 25, 2026
+
+
+
+
💻
+
+
Implement Authentication
+
PDP Project • Priority: High
+
+
+
+
📊
+
+
Prepare Monthly Report
+
Statistics • Due by August 1st
+
+
+
+
+ +
+
+ +
+
+
+
📖
+
+
Learn Scala 3
+
Plan • Start in August
+
+
+
+
🎯
+
+
Launch MVP project
+
Plan • Goal: Q4 2026
+
+
+
+
📈
+
+
Course Typelevel Stack
+
Training • Planned for September
+
+
+
+
+ +
+
+ +
+
+
+
📄
+
+
Krop Documentation
+
Knowledge Base • Read and Take Notes
+
+
+
+
🎓
+
+
FP Lectures
+
Knowledge Base • View and Write Questions
+
+
+
+
+
+ +
+
+
\ No newline at end of file diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 3f2fad2..e49ed0e 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -8,7 +8,7 @@ object Dependencies { val catsVersion = "2.10.0" val catsEffectVersion = "3.5.1" val circeVersion = "0.14.13" - val circeGenericVersion = "0.14.15" + val circeGenericVersion = "0.14.16" val declineVersion = "2.6.2" val fs2Version = "3.6.1" val http4sVersion = "1.0.0-M47" diff --git a/project/KropTwirlLayout.scala b/project/KropTwirlLayout.scala new file mode 100644 index 0000000..a8b6b3d --- /dev/null +++ b/project/KropTwirlLayout.scala @@ -0,0 +1,25 @@ +// A simple plugin that sets the directory layout for Twirl to the Krop +// standard. This removes the excessive indirection the Maven standard uses. +// Inspired by PlayLayoutPlugin. + +import sbt.* +import sbt.Keys.* + +import play.twirl.sbt.SbtTwirl +import play.twirl.sbt.Import.TwirlKeys + +object KropTwirlLayout extends AutoPlugin { + // Must be explicitly enabled + override def trigger = noTrigger + + override def requires = SbtTwirl + + override def projectSettings = Seq( + Compile / TwirlKeys.compileTemplates / sourceDirectories := Seq( + (Compile / scalaSource).value + ), + Test / TwirlKeys.compileTemplates / sourceDirectories := Seq( + (Test / scalaSource).value + ) + ) +} diff --git a/project/plugins.sbt b/project/plugins.sbt index 535620e..7fc4a26 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -7,3 +7,4 @@ addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.8.7") addSbtPlugin("org.typelevel" % "sbt-typelevel-site" % "0.8.7") addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.12") addSbtPlugin("org.creativescala" % "creative-scala-theme" % "0.6.4") +addSbtPlugin("org.playframework.twirl" % "sbt-twirl" % "2.0.9")