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
+ 
+- **Registration page** – New users can create an account
+ 
+- **Authenticated dashboard** – After login, users see a personalized welcome page with task management tabs
+ 
+- **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)
+
+
+
+
+
+
+
+ @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])
+
+```
+
+#### register.scala.html
+
+**`register.scala.html`** – The registration form, similar to the login page but for new user creation.
+
+```html
+@(errorMessage: Option[String])
+
+```
+
+#### 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
+
+
+
+
+
+
+
+
\ 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])
+
\ 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")