~jan0sch/smederee

Showing details for patch 9c753d0270a7b42c6d3e87d0773e3b47b220d9b3.
2024-06-29 (Sat), 1:12 PM - Jens Grassel - 9c753d0270a7b42c6d3e87d0773e3b47b220d9b3

Add `HttpBaseRoute` for cleaner implementation of HTTP enpoints via http4s.

- add `HttpBaseRoute`
- extending requires overriding of `protectedRoutes` and `publicRoutes`
- add helper function `genPageTitleBase` for more consistent page titles
Summary of changes
2 files added
  • modules/hub/src/main/scala/de/smederee/hub/HttpBaseRoute.scala
  • modules/hub/src/test/scala/de/smederee/hub/HttpBaseRouteTest.scala
10 files modified with 93 lines added and 51 lines removed
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala with 1 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala with 1 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala with 1 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala with 2 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRoutes.scala with 1 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala with 2 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 17 added and 13 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala with 18 added and 8 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala with 24 added and 9 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala with 26 added and 9 removed lines
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2025-01-11 12:01:12.692589499 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2025-01-11 12:01:12.696589506 +0000
@@ -40,7 +40,6 @@
 import de.smederee.tickets.TicketServiceApi
 import de.smederee.tickets.TicketsUser
 import org.http4s.*
-import org.http4s.dsl.*
 import org.http4s.headers.Location
 import org.http4s.implicits.*
 import org.http4s.twirl.TwirlInstances.*
@@ -73,7 +72,7 @@
     organisationsRepo: OrganisationRepository[F],
     signAndValidate: SignAndValidate,
     ticketServiceApi: TicketServiceApi[F]
-) extends Http4sDsl[F] {
+) extends HttpBaseRoute[F] {
     private val log = LoggerFactory.getLogger(getClass)
 
     /** Delete the given directory recursively. It is checked if the given directory is a direct sub directory of the
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala	2025-01-11 12:01:12.692589499 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala	2025-01-11 12:01:12.696589506 +0000
@@ -32,7 +32,6 @@
 import de.smederee.hub.forms.types.FormFieldError
 import de.smederee.security.*
 import org.http4s.*
-import org.http4s.dsl.Http4sDsl
 import org.http4s.headers.Location
 import org.http4s.implicits.*
 import org.http4s.twirl.TwirlInstances.*
@@ -79,7 +78,7 @@
     external: ExternalUrlConfiguration,
     repo: AuthenticationRepository[F],
     signAndValidate: SignAndValidate
-) extends Http4sDsl[F] {
+) extends HttpBaseRoute[F] {
     private val log = LoggerFactory.getLogger(getClass)
 
     private val loginPath = uri"/login"
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HttpBaseRoute.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HttpBaseRoute.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HttpBaseRoute.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HttpBaseRoute.scala	2025-01-11 12:01:12.696589506 +0000
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import cats.effect.*
+import de.smederee.security.Username
+import de.smederee.tickets.ProjectName
+import org.http4s.AuthedRoutes
+import org.http4s.HttpRoutes
+import org.http4s.dsl.Http4sDsl
+
+/** A base class for HTTP routes to ease consistent structuring of them. This class should be extended if HTTP endpoints
+  * are implemented via http4s.
+  *
+  * @tparam F
+  *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
+  */
+abstract class HttpBaseRoute[F[_]: Async] extends Http4sDsl[F] with HttpBaseRouteHelpers {
+
+    /** All protected (i.e. AuthedRoutes) that are implemented by the routing class.
+      *
+      * @return
+      *   A collection of protected routes which may be empty (i.e. `AuthedRoutes.empty`).
+      */
+    def protectedRoutes: AuthedRoutes[Account, F]
+
+    /** All public (unprotected) routes that are implemented by the routing class.
+      *
+      * @return
+      *   A collection of routes that might by empty (i.e. `HttpRoutes.empty`).
+      */
+    def routes: HttpRoutes[F]
+}
+
+trait HttpBaseRouteHelpers {
+
+    /** A helper function to provide a consistent web page title generator. It generates the base of a page title that
+      * may be extended further.
+      *
+      * @param ownerName
+      *   The name of the owner of a resource (project, repo, etc.).
+      * @param An
+      *   optional resource name (project, repo, etc.).
+      * @return
+      *   A base string for the web page title header.
+      */
+    def genPageTitleBase(ownerName: Username)(repoOrProject: Option[ProjectName | VcsRepositoryName]): String = {
+        val prefix = s"~$ownerName"
+        repoOrProject.fold(prefix)(name => prefix + s"/$name")
+    }
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala	2025-01-11 12:01:12.692589499 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala	2025-01-11 12:01:12.696589506 +0000
@@ -24,7 +24,6 @@
 import de.smederee.hub.config.*
 import de.smederee.i18n.LanguageCode
 import org.http4s.*
-import org.http4s.dsl.Http4sDsl
 import org.http4s.implicits.*
 import org.http4s.twirl.TwirlInstances.*
 
@@ -38,7 +37,7 @@
   * @tparam F
   *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
   */
-final class LandingPageRoutes[F[_]: Async](configuration: ServiceConfig) extends Http4sDsl[F] {
+final class LandingPageRoutes[F[_]: Async](configuration: ServiceConfig) extends HttpBaseRoute[F] {
     private val linkConfig = configuration.external
     // The base URI for our site which that be passed into some templates which create links themselfes.
     private val baseUri = linkConfig.createFullUri(Uri())
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala	2025-01-11 12:01:12.696589506 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/OrganisationRoutes.scala	2025-01-11 12:01:12.696589506 +0000
@@ -34,7 +34,6 @@
 import de.smederee.i18n.LanguageCode
 import de.smederee.security.*
 import org.http4s.*
-import org.http4s.dsl.Http4sDsl
 import org.http4s.headers.*
 import org.http4s.implicits.*
 import org.http4s.twirl.TwirlInstances.*
@@ -50,7 +49,7 @@
   *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
   */
 final class OrganisationRoutes[F[_]: Async](configuration: ServiceConfig, orgRepo: OrganisationRepository[F])
-    extends Http4sDsl[F] {
+    extends HttpBaseRoute[F] {
     private val log                               = LoggerFactory.getLogger(getClass)
     given org.typelevel.log4cats.LoggerFactory[F] = org.typelevel.log4cats.slf4j.Slf4jFactory.create[F]
 
@@ -525,4 +524,5 @@
     val protectedRoutes =
         showCreateOrganisationForm <+> createOrganisation <+> showEditOrganisationAdminsForm <+> showEditOrganisationForm <+> deleteOrganisation <+> editOrganisationAdmins <+> editOrganisation
 
+    val routes = HttpRoutes.empty
 }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRoutes.scala	2025-01-11 12:01:12.696589506 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRoutes.scala	2025-01-11 12:01:12.696589506 +0000
@@ -33,7 +33,6 @@
 import de.smederee.hub.forms.types.FormFieldError
 import de.smederee.i18n.LanguageCode
 import org.http4s.*
-import org.http4s.dsl.Http4sDsl
 import org.http4s.headers.Location
 import org.http4s.implicits.*
 import org.http4s.twirl.TwirlInstances.*
@@ -47,7 +46,7 @@
     emailMiddleware: EmailMiddleware[F],
     external: ExternalUrlConfiguration,
     resetPasswordRepo: ResetPasswordRepository[F]
-) extends Http4sDsl[F] {
+) extends HttpBaseRoute[F] {
     private val log = LoggerFactory.getLogger(getClass)
 
     private val loginPath               = uri"/login"
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala	2025-01-11 12:01:12.696589506 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala	2025-01-11 12:01:12.696589506 +0000
@@ -27,7 +27,6 @@
 import de.smederee.hub.forms.types.FormFieldError
 import de.smederee.security.*
 import org.http4s.*
-import org.http4s.dsl.Http4sDsl
 import org.http4s.headers.Location
 import org.http4s.implicits.*
 import org.http4s.twirl.TwirlInstances.*
@@ -42,7 +41,8 @@
   * @tparam F
   *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
   */
-final class SignupRoutes[F[_]: Async](configuration: ServiceConfig, repo: SignupRepository[F]) extends Http4sDsl[F] {
+final class SignupRoutes[F[_]: Async](configuration: ServiceConfig, repo: SignupRepository[F])
+    extends HttpBaseRoute[F] {
     private val log          = LoggerFactory.getLogger(getClass)
     private val linkConfig   = configuration.external
     private val signupConfig = configuration.signup
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-11 12:01:12.696589506 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-11 12:01:12.696589506 +0000
@@ -37,7 +37,6 @@
 import de.smederee.tickets.ProjectRepository
 import org.fusesource.jansi.utils.UtilsAnsiHtml
 import org.http4s.*
-import org.http4s.dsl.Http4sDsl
 import org.http4s.dsl.impl.*
 import org.http4s.headers.*
 import org.http4s.implicits.*
@@ -66,7 +65,7 @@
     vcsMetadataRepo: VcsMetadataRepository[F],
     ticketsProjectRepo: ProjectRepository[F],
     orgRepo: OrganisationRepository[F]
-) extends Http4sDsl[F] {
+) extends HttpBaseRoute[F] {
     private val log                               = LoggerFactory.getLogger(getClass)
     given org.typelevel.log4cats.LoggerFactory[F] = org.typelevel.log4cats.slf4j.Slf4jFactory.create[F]
 
@@ -325,6 +324,7 @@
             organisationAdmins = organisationOwner.flatten
                 .map(account => List(account))
                 .getOrElse(Nil) ::: possibleOrgaAdmins.getOrElse(Nil)
+            pageTitle = genPageTitleBase(repositoriesOwnerName)(None)
             resp <- (owner, organisation) match {
                 case (Some(owner), organisation) =>
                     loadRepos(owner).compile.toList.flatMap { repos =>
@@ -332,7 +332,7 @@
                             views.html.showRepositories(lang = language)(
                                 actionBaseUri,
                                 csrf,
-                                s"Smederee/~$repositoriesOwnerName".some,
+                                pageTitle.some,
                                 user
                             )(repos, repositoriesOwnerName, organisation, organisationActionBaseUri, organisationAdmins)
                         )
@@ -342,7 +342,7 @@
                         views.html.showRepositories(lang = language)(
                             actionBaseUri,
                             csrf,
-                            s"Smederee/~$repositoriesOwnerName".some,
+                            pageTitle.some,
                             user
                         )(Nil, repositoriesOwnerName, organisation.some, organisationActionBaseUri, organisationAdmins)
                     )
@@ -350,7 +350,7 @@
                     NotFound(
                         views.html.errors.userOrOrganisationNotFound(lang = language)(
                             csrf,
-                            s"Smederee/~$repositoriesOwnerName".some,
+                            pageTitle.some,
                             user
                         )(repositoriesOwnerName)
                     )
@@ -397,6 +397,7 @@
                 case None       => Sync[F].pure(None)
                 case Some(repo) => vcsMetadataRepo.findVcsRepositoryParentFork(repo.owner, repo.name)
             }
+            pageTitle = genPageTitleBase(repositoryOwnerName)(repositoryName.some)
             resp <- repo match {
                 case None => NotFound("Repository not found!")
                 case Some(repo) =>
@@ -405,7 +406,7 @@
                             actionBaseUri,
                             csrf,
                             linkToTicketService,
-                            s"Smederee/~$repositoryOwnerName/$repositoryName".some,
+                            pageTitle.some,
                             user
                         )(repo, branches)
                     )
@@ -504,6 +505,7 @@
                 case None       => Sync[F].pure(None)
                 case Some(repo) => vcsMetadataRepo.findVcsRepositoryParentFork(repo.owner, repo.name)
             }
+            pageTitle = genPageTitleBase(repositoryOwnerName)(repositoryName.some)
             resp <- repo match {
                 case None => NotFound("Repository not found!")
                 case Some(repo) =>
@@ -512,7 +514,7 @@
                             actionBaseUri,
                             csrf,
                             linkToTicketService,
-                            s"Smederee/~$repositoryOwnerName/$repositoryName".some,
+                            pageTitle.some,
                             user
                         )(
                             repo,
@@ -645,9 +647,8 @@
                 case Some(repoId) => vcsMetadataRepo.findVcsRepositoryBranches(repoId).compile.toList
                 case _            => Sync[F].delay(List.empty)
             }
-            title <- Sync[F].delay(
-                s"Smederee/~$repositoryOwnerName/$repositoryName/${filePath.segments.map(_.decoded()).mkString("/")}"
-            )
+            titleBase = genPageTitleBase(repositoryOwnerName)(repositoryName.some)
+            pageTitle <- Sync[F].delay(s"$titleBase/${filePath.segments.map(_.decoded()).mkString("/")}")
             resp <-
                 repo match {
                     case None => NotFound("Repository not found!")
@@ -664,7 +665,7 @@
                                     csrf,
                                     goBackUri.some,
                                     linkToTicketService,
-                                    title.some,
+                                    pageTitle.some,
                                     user
                                 )(fileContent, renderableContent, listing, repositoryBaseUri, repo, branches)
                             )
@@ -758,6 +759,7 @@
                     )
                 )
             )
+            pageTitle = genPageTitleBase(repositoryOwnerName)(repositoryName.some) |+| " - History of changes"
             resp <- repo match {
                 case None => NotFound()
                 case Some(repo) =>
@@ -767,7 +769,7 @@
                             csrf,
                             goBackUri.some,
                             linkToTicketService,
-                            s"Smederee - History of ~$repositoryOwnerName/$repositoryName".some,
+                            pageTitle.some,
                             user
                         )(patches, next, repositoryBaseUri, repo, branches)
                     )
@@ -912,6 +914,8 @@
                     )
                 )
             )
+            titleBase = genPageTitleBase(repositoryOwnerName)(repositoryName.some)
+            pageTitle = patch.map(_.name).map(patchName => titleBase |+| " " |+| patchName.toString)
             resp <- repo match {
                 case None => NotFound()
                 case Some(repo) =>
@@ -921,7 +925,7 @@
                                 actionBaseUri,
                                 csrf,
                                 linkToTicketService,
-                                patch.map(_.name.toString),
+                                pageTitle,
                                 user
                             )(
                                 patch,
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala	2025-01-11 12:01:12.696589506 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala	2025-01-11 12:01:12.696589506 +0000
@@ -24,6 +24,7 @@
 import de.smederee.html.*
 import de.smederee.html.LinkTools.*
 import de.smederee.hub.Account
+import de.smederee.hub.HttpBaseRoute
 import de.smederee.hub.RequestHelpers.instances.given
 import de.smederee.i18n.LanguageCode
 import de.smederee.security.CsrfToken
@@ -31,7 +32,6 @@
 import de.smederee.tickets.config.*
 import de.smederee.tickets.forms.types.*
 import org.http4s.*
-import org.http4s.dsl.Http4sDsl
 import org.http4s.headers.Location
 import org.http4s.twirl.TwirlInstances.*
 import org.slf4j.LoggerFactory
@@ -51,7 +51,7 @@
     configuration: SmedereeTicketsConfiguration,
     labelRepo: LabelRepository[F],
     projectRepo: ProjectRepository[F]
-) extends Http4sDsl[F] {
+) extends HttpBaseRoute[F] {
     private val log = LoggerFactory.getLogger(getClass)
 
     private val linkToHubService = configuration.hub.baseUri
@@ -93,6 +93,7 @@
                                 )
                             )
                         )
+                        pageTitle = genPageTitleBase(projectOwnerName)(projectName.some) |+| " Manage your labels."
                         resp <- Ok(
                             views.html.editLabels(lang = language)(
                                 projectBaseUri.addSegment("labels"),
@@ -100,7 +101,7 @@
                                 linkToHubService,
                                 labels,
                                 projectBaseUri,
-                                "Manage your project labels.".some,
+                                pageTitle.some,
                                 user,
                                 project
                             )()
@@ -182,6 +183,9 @@
                                             )
                                         )
                                     )
+                                    pageTitle = genPageTitleBase(projectOwnerName)(
+                                        projectName.some
+                                    ) |+| " Manage your labels."
                                     resp <- form match {
                                         case Validated.Invalid(errors) =>
                                             BadRequest(
@@ -191,7 +195,7 @@
                                                     linkToHubService,
                                                     labels.getOrElse(List.empty),
                                                     projectBaseUri,
-                                                    "Manage your project labels.".some,
+                                                    pageTitle.some,
                                                     user.some,
                                                     project
                                                 )(formData.withDefaultValue(Chain.empty), FormErrors.fromNec(errors))
@@ -214,7 +218,7 @@
                                                                 linkToHubService,
                                                                 labels.getOrElse(List.empty),
                                                                 projectBaseUri,
-                                                                "Manage your project labels.".some,
+                                                                pageTitle.some,
                                                                 user.some,
                                                                 project
                                                             )(
@@ -367,6 +371,9 @@
 //                  }
 //                )
                                     form <- Sync[F].delay(LabelForm.validate(formData))
+                                    pageTitle = genPageTitleBase(projectOwnerName)(
+                                        projectName.some
+                                    ) |+| s" - Edit label ${label.name}"
                                     resp <- form match {
                                         case Validated.Invalid(errors) =>
                                             BadRequest(
@@ -376,7 +383,7 @@
                                                     linkToHubService,
                                                     label,
                                                     projectBaseUri,
-                                                    s"Edit label ${label.name}".some,
+                                                    pageTitle.some,
                                                     user,
                                                     project
                                                 )(
@@ -406,7 +413,7 @@
                                                                 linkToHubService,
                                                                 label,
                                                                 projectBaseUri,
-                                                                s"Edit label ${label.name}".some,
+                                                                pageTitle.some,
                                                                 user,
                                                                 project
                                                             )(
@@ -470,6 +477,9 @@
                                 projectBaseUri.addSegment("labels").addSegment(label.name.toString)
                             )
                             formData <- Sync[F].delay(LabelForm.fromLabel(label))
+                            pageTitle = genPageTitleBase(projectOwnerName)(
+                                projectName.some
+                            ) |+| s" - Edit label ${label.name}"
                             resp <- Ok(
                                 views.html
                                     .editLabel()(
@@ -478,7 +488,7 @@
                                         linkToHubService,
                                         label,
                                         projectBaseUri,
-                                        s"Edit label ${label.name}".some,
+                                        pageTitle.some,
                                         user,
                                         project
                                     )(
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-11 12:01:12.696589506 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-11 12:01:12.696589506 +0000
@@ -24,6 +24,7 @@
 import de.smederee.html.*
 import de.smederee.html.LinkTools.*
 import de.smederee.hub.Account
+import de.smederee.hub.HttpBaseRoute
 import de.smederee.hub.RequestHelpers.instances.given
 import de.smederee.i18n.LanguageCode
 import de.smederee.security.CsrfToken
@@ -31,7 +32,6 @@
 import de.smederee.tickets.config.*
 import de.smederee.tickets.forms.types.*
 import org.http4s.*
-import org.http4s.dsl.Http4sDsl
 import org.http4s.headers.Location
 import org.http4s.twirl.TwirlInstances.*
 import org.slf4j.LoggerFactory
@@ -51,7 +51,7 @@
     configuration: SmedereeTicketsConfiguration,
     milestoneRepo: MilestoneRepository[F],
     projectRepo: ProjectRepository[F]
-) extends Http4sDsl[F] {
+) extends HttpBaseRoute[F] {
     private val log = LoggerFactory.getLogger(getClass)
 
     private val linkToHubService = configuration.hub.baseUri
@@ -110,6 +110,9 @@
                                     ContentRenderer.render(None)(RenderableContent.Markdown)(content).toOption
                                 )
                         )
+                        pageTitle = genPageTitleBase(projectOwnerName)(
+                            projectName.some
+                        ) |+| s" Milestone ${milestone.title}"
                         resp <- Ok(
                             views.html.showMilestone(lang = language)(
                                 actionUri,
@@ -119,7 +122,7 @@
                                 renderedDescription,
                                 projectBaseUri,
                                 tickets.getOrElse(Nil),
-                                s"Milestone ${milestone.title}".some,
+                                pageTitle.some,
                                 user,
                                 project
                             )
@@ -164,6 +167,9 @@
                                 )
                             )
                         )
+                        pageTitle = genPageTitleBase(projectOwnerName)(
+                            projectName.some
+                        ) |+| " - Manage your project milestones."
                         resp <- Ok(
                             views.html.editMilestones(lang = language)(
                                 projectBaseUri.addSegment("milestones"),
@@ -171,7 +177,7 @@
                                 linkToHubService,
                                 milestones,
                                 projectBaseUri,
-                                "Manage your project milestones.".some,
+                                pageTitle.some,
                                 user,
                                 project
                             )()
@@ -252,6 +258,9 @@
                                         )
                                     )
                                 )
+                                pageTitle = genPageTitleBase(projectOwnerName)(
+                                    projectName.some
+                                ) |+| " - Manage your project milestones."
                                 resp <- form match {
                                     case Validated.Invalid(errors) =>
                                         BadRequest(
@@ -261,7 +270,7 @@
                                                 linkToHubService,
                                                 milestones.getOrElse(List.empty),
                                                 projectBaseUri,
-                                                "Manage your project milestones.".some,
+                                                pageTitle.some,
                                                 user.some,
                                                 project
                                             )(formData, FormErrors.fromNec(errors))
@@ -292,7 +301,7 @@
                                                             linkToHubService,
                                                             milestones.getOrElse(List.empty),
                                                             projectBaseUri,
-                                                            "Manage your project milestones.".some,
+                                                            pageTitle.some,
                                                             user.some,
                                                             project
                                                         )(
@@ -510,6 +519,9 @@
                                         )(_ => milestone.id.validNec)
                                 )
                                 form <- Sync[F].delay(MilestoneForm.validate(formData))
+                                pageTitle = genPageTitleBase(projectOwnerName)(
+                                    projectName.some
+                                ) |+| s" - Edit milestone ${milestone.title}"
                                 resp <- form match {
                                     case Validated.Invalid(errors) =>
                                         BadRequest(
@@ -519,7 +531,7 @@
                                                 linkToHubService,
                                                 milestone,
                                                 projectBaseUri,
-                                                s"Edit milestone ${milestone.title}".some,
+                                                pageTitle.some,
                                                 user,
                                                 project
                                             )(
@@ -551,7 +563,7 @@
                                                             linkToHubService,
                                                             milestone,
                                                             projectBaseUri,
-                                                            s"Edit milestone ${milestone.title}".some,
+                                                            pageTitle.some,
                                                             user,
                                                             project
                                                         )(
@@ -678,6 +690,9 @@
                                 projectBaseUri.addSegment("milestones").addSegment(milestone.title.toString)
                             )
                             formData <- Sync[F].delay(MilestoneForm.fromMilestone(milestone))
+                            pageTitle = genPageTitleBase(projectOwnerName)(
+                                projectName.some
+                            ) |+| s" - Edit milestone ${milestone.title}"
                             resp <- Ok(
                                 views.html.editMilestone(lang = language)(
                                     actionUri,
@@ -685,7 +700,7 @@
                                     linkToHubService,
                                     milestone,
                                     projectBaseUri,
-                                    s"Edit milestone ${milestone.title}".some,
+                                    pageTitle.some,
                                     user,
                                     project
                                 )(
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-11 12:01:12.696589506 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-11 12:01:12.696589506 +0000
@@ -27,6 +27,7 @@
 import de.smederee.html.*
 import de.smederee.html.LinkTools.*
 import de.smederee.hub.Account
+import de.smederee.hub.HttpBaseRoute
 import de.smederee.hub.RequestHelpers.instances.given
 import de.smederee.i18n.LanguageCode
 import de.smederee.security.CsrfToken
@@ -34,7 +35,6 @@
 import de.smederee.tickets.config.*
 import de.smederee.tickets.forms.types.*
 import org.http4s.*
-import org.http4s.dsl.Http4sDsl
 import org.http4s.headers.Location
 import org.http4s.twirl.TwirlInstances.*
 import org.slf4j.LoggerFactory
@@ -60,7 +60,7 @@
     milestoneRepo: MilestoneRepository[F],
     projectRepo: ProjectRepository[F],
     ticketRepo: TicketRepository[F]
-) extends Http4sDsl[F] {
+) extends HttpBaseRoute[F] {
     private val log = LoggerFactory.getLogger(getClass)
 
     private val linkToHubService = configuration.hub.baseUri
@@ -152,6 +152,9 @@
                                     ContentRenderer.render(None)(RenderableContent.Markdown)(content).toOption
                                 )
                         )
+                        pageTitle = genPageTitleBase(projectOwnerName)(
+                            projectName.some
+                        ) |+| s" #${ticket.number} ${ticket.title}"
                         resp <- Ok(
                             views.html.showTicket(lang = language)(
                                 projectBaseUri.addSegment("tickets"),
@@ -162,7 +165,7 @@
                                 ticket,
                                 renderedTicketContent,
                                 projectBaseUri,
-                                ticket.title.toString.some,
+                                pageTitle.some,
                                 user,
                                 project
                             )
@@ -215,7 +218,9 @@
                             .filter(_.nonEmpty)
                             .map(stati => s" (${stati.mkString(", ")})")
                             .getOrElse("")
-                        title = s"Smederee/~$projectOwnerName/$projectName - Tickets" |+| ticketStati
+                        pageTitle = genPageTitleBase(projectOwnerName)(
+                            projectName.some
+                        ) |+| " - Tickets" |+| ticketStati
                         resp <- Ok(
                             views.html.showTickets(lang = language)(
                                 projectBaseUri.addSegment("tickets"),
@@ -224,7 +229,7 @@
                                 tickets,
                                 filter,
                                 projectBaseUri,
-                                title.some,
+                                pageTitle.some,
                                 user,
                                 project
                             )
@@ -266,6 +271,9 @@
                                             )
                                         )
                                     )
+                                    pageTitle = genPageTitleBase(projectOwnerName)(
+                                        projectName.some
+                                    ) |+| " Create a new ticket."
                                     resp <- form match {
                                         case Validated.Invalid(errors) =>
                                             BadRequest(
@@ -276,7 +284,7 @@
                                                     labels,
                                                     milestones,
                                                     projectBaseUri,
-                                                    "Create a new ticket.".some,
+                                                    pageTitle.some,
                                                     user.some,
                                                     project
                                                 )(formData.withDefaultValue(Chain.empty), FormErrors.fromNec(errors))
@@ -364,6 +372,9 @@
                                             )
                                         )
                                     )
+                                    pageTitle = genPageTitleBase(projectOwnerName)(
+                                        projectName.some
+                                    ) |+| " Create a new ticket."
                                     resp <- form match {
                                         case Validated.Invalid(errors) =>
                                             BadRequest(
@@ -374,7 +385,7 @@
                                                     labels,
                                                     milestones,
                                                     projectBaseUri,
-                                                    "Create a new ticket.".some,
+                                                    pageTitle.some,
                                                     user.some,
                                                     project
                                                 )(formData.withDefaultValue(Chain.empty), FormErrors.fromNec(errors))
@@ -466,6 +477,9 @@
                                         )
                                     )
                                 )
+                                pageTitle = genPageTitleBase(projectOwnerName)(
+                                    projectName.some
+                                ) |+| " Create a new ticket."
                                 resp <- Ok(
                                     views.html.createTicket(lang = language)(
                                         projectBaseUri.addSegment("tickets"),
@@ -474,7 +488,7 @@
                                         labels,
                                         milestones,
                                         projectBaseUri,
-                                        "Create a new ticket.".some,
+                                        pageTitle.some,
                                         user.some,
                                         project
                                     )()
@@ -532,6 +546,9 @@
                                         )
                                     )
                                 )
+                                pageTitle = genPageTitleBase(projectOwnerName)(
+                                    projectName.some
+                                ) |+| s"Edit ticket ${ticket.number}"
                                 resp <- Ok(
                                     views.html.editTicket(lang = language)(
                                         projectBaseUri.addSegment("tickets"),
@@ -541,7 +558,7 @@
                                         milestones,
                                         projectBaseUri,
                                         ticket.number,
-                                        s"Edit ticket ${ticket.number}".some,
+                                        pageTitle.some,
                                         user.some,
                                         project
                                     )(formData.withDefaultValue(Chain.empty), FormErrors.empty)
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/HttpBaseRouteTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/HttpBaseRouteTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/HttpBaseRouteTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/HttpBaseRouteTest.scala	2025-01-11 12:01:12.700589515 +0000
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import cats.syntax.all.*
+import de.smederee.hub.Generators.genValidVcsRepository
+import de.smederee.hub.Generators.given
+import de.smederee.tickets.Generators.genProject
+import de.smederee.tickets.Project
+
+import munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class HttpBaseRouteTest extends ScalaCheckSuite with HttpBaseRouteHelpers {
+    private given Arbitrary[Project]       = Arbitrary(genProject)
+    private given Arbitrary[VcsRepository] = Arbitrary(genValidVcsRepository)
+
+    property("genPageTitleBase must work correctly for only usernames") {
+        forAll { (account: Account) =>
+            val expected = s"~${account.name}"
+            assertEquals(genPageTitleBase(account.name)(None), expected)
+        }
+    }
+
+    property("genPageTitleBase must work correctly for user and repository names") {
+        forAll { (account: Account, repo: VcsRepository) =>
+            val expected = s"~${account.name}/${repo.name}"
+            assertEquals(genPageTitleBase(account.name)(repo.name.some), expected)
+        }
+    }
+
+    property("genPageTitleBase must work correctly for user and project names") {
+        forAll { (account: Account, project: Project) =>
+            val expected = s"~${account.name}/${project.name}"
+            assertEquals(genPageTitleBase(account.name)(project.name.some), expected)
+        }
+    }
+}