diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala index 0140851..f970506 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala @@ -12,17 +12,30 @@ import cz.e_bs.cmi.mdr.pdb.ParameterCriteria // enum is not working with Waypoints' SplitRender collectStatic -sealed abstract class Page(val title: String, val parent: Option[Page]) +sealed abstract class Page( + val id: String, + val title: String, + val parent: Option[Page] +) { + val path: Vector[Page] = + parent match + case None => Vector(this) + case Some(p) => p.path :+ this +} object Page: - case object Directory extends Page("Adresář", None) + case object Directory extends Page("directory", "Adresář", None) - case object Dashboard extends Page("Přehled", Some(Directory)) + case object Dashboard extends Page("dashboard", "Přehled", Some(Directory)) // TODO: refactor to some "NamedParameter" concept, where the tuples value + title are better managed case class Detail(osobniCislo: OsobniCislo, jmenoOsoby: Option[String] = None) - extends Page(jmenoOsoby.getOrElse("Detail osoby"), Some(Directory)) + extends Page( + "user", + jmenoOsoby.getOrElse("Detail osoby"), + Some(Directory) + ) object Detail { def apply(o: UserInfo): Detail = Detail(o.personalNumber, Some(o.name)) @@ -34,6 +47,7 @@ jmenoOsoby: Option[String] = None, nazevParametru: Option[String] = None ) extends Page( + "parameter", nazevParametru.getOrElse("Detail parametru"), Some(Detail(osobniCislo, jmenoOsoby)) ) @@ -51,6 +65,7 @@ nazevParametru: Option[String] = None, nazevKriteria: Option[String] = None ) extends Page( + "criteria", nazevKriteria.getOrElse("Detail kriteria"), Some( DetailParametru(osobniCislo, idParametru, jmenoOsoby, nazevParametru) @@ -69,12 +84,12 @@ ) } - case class NotFound(url: String) extends Page("404", Some(Directory)) + case class NotFound(url: String) extends Page("404", "404", Some(Directory)) case class UnhandledError( errorName: Option[String], errorMessage: Option[String] - ) extends Page("Unexpected error", Some(Directory)) + ) extends Page("500", "Unexpected error", Some(Directory)) object Routes: given JsonDecoder[OsobniCislo] = JsonDecoder.string.map(OsobniCislo.apply) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala index 0140851..f970506 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala @@ -12,17 +12,30 @@ import cz.e_bs.cmi.mdr.pdb.ParameterCriteria // enum is not working with Waypoints' SplitRender collectStatic -sealed abstract class Page(val title: String, val parent: Option[Page]) +sealed abstract class Page( + val id: String, + val title: String, + val parent: Option[Page] +) { + val path: Vector[Page] = + parent match + case None => Vector(this) + case Some(p) => p.path :+ this +} object Page: - case object Directory extends Page("Adresář", None) + case object Directory extends Page("directory", "Adresář", None) - case object Dashboard extends Page("Přehled", Some(Directory)) + case object Dashboard extends Page("dashboard", "Přehled", Some(Directory)) // TODO: refactor to some "NamedParameter" concept, where the tuples value + title are better managed case class Detail(osobniCislo: OsobniCislo, jmenoOsoby: Option[String] = None) - extends Page(jmenoOsoby.getOrElse("Detail osoby"), Some(Directory)) + extends Page( + "user", + jmenoOsoby.getOrElse("Detail osoby"), + Some(Directory) + ) object Detail { def apply(o: UserInfo): Detail = Detail(o.personalNumber, Some(o.name)) @@ -34,6 +47,7 @@ jmenoOsoby: Option[String] = None, nazevParametru: Option[String] = None ) extends Page( + "parameter", nazevParametru.getOrElse("Detail parametru"), Some(Detail(osobniCislo, jmenoOsoby)) ) @@ -51,6 +65,7 @@ nazevParametru: Option[String] = None, nazevKriteria: Option[String] = None ) extends Page( + "criteria", nazevKriteria.getOrElse("Detail kriteria"), Some( DetailParametru(osobniCislo, idParametru, jmenoOsoby, nazevParametru) @@ -69,12 +84,12 @@ ) } - case class NotFound(url: String) extends Page("404", Some(Directory)) + case class NotFound(url: String) extends Page("404", "404", Some(Directory)) case class UnhandledError( errorName: Option[String], errorMessage: Option[String] - ) extends Page("Unexpected error", Some(Directory)) + ) extends Page("500", "Unexpected error", Some(Directory)) object Routes: given JsonDecoder[OsobniCislo] = JsonDecoder.string.map(OsobniCislo.apply) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala index ea5600a..e77d2a3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -6,30 +6,25 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import com.raquo.waypoint.Router -class AppPage(using router: Router[Page]) - extends PageLayout - with PageHeader - with Breadcrumbs - with NavigationBar[Page] - with Navigator[Page]: +object AppPage: // TODO: pages by logged in user val pages = List(Page.Directory, Page.Dashboard) - override val logo = Logo( + import NavigationBar.{Logo, MenuItem} + + val logo = Logo( "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", "Workflow" ) // TODO: menu items by user profile - override val userMenu = + val userMenu = List( MenuItem("Your Profile"), MenuItem("Settings"), MenuItem("Sign out") ) - override def pageTitle(page: Page): String = page.title - // TODO: load user profile val $userProfile = Var( UserProfile( @@ -50,4 +45,23 @@ ) ) - override val $userInfo = $userProfile.signal.map(_.userInfo) + val $userInfo = $userProfile.signal.map(_.userInfo) + + type ViewModel = Option[HtmlElement] + def render($m: Signal[ViewModel], mods: Modifier[HtmlElement]*)(using + Router[Page] + ): HtmlElement = + PageLayout.render( + $m.combineWith($userInfo).map((c, u) => + PageLayout.ViewModel( + NavigationBar.ViewModel( + u, + pages, + userMenu, + logo + ), + c + ) + ), + mods + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala index 0140851..f970506 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala @@ -12,17 +12,30 @@ import cz.e_bs.cmi.mdr.pdb.ParameterCriteria // enum is not working with Waypoints' SplitRender collectStatic -sealed abstract class Page(val title: String, val parent: Option[Page]) +sealed abstract class Page( + val id: String, + val title: String, + val parent: Option[Page] +) { + val path: Vector[Page] = + parent match + case None => Vector(this) + case Some(p) => p.path :+ this +} object Page: - case object Directory extends Page("Adresář", None) + case object Directory extends Page("directory", "Adresář", None) - case object Dashboard extends Page("Přehled", Some(Directory)) + case object Dashboard extends Page("dashboard", "Přehled", Some(Directory)) // TODO: refactor to some "NamedParameter" concept, where the tuples value + title are better managed case class Detail(osobniCislo: OsobniCislo, jmenoOsoby: Option[String] = None) - extends Page(jmenoOsoby.getOrElse("Detail osoby"), Some(Directory)) + extends Page( + "user", + jmenoOsoby.getOrElse("Detail osoby"), + Some(Directory) + ) object Detail { def apply(o: UserInfo): Detail = Detail(o.personalNumber, Some(o.name)) @@ -34,6 +47,7 @@ jmenoOsoby: Option[String] = None, nazevParametru: Option[String] = None ) extends Page( + "parameter", nazevParametru.getOrElse("Detail parametru"), Some(Detail(osobniCislo, jmenoOsoby)) ) @@ -51,6 +65,7 @@ nazevParametru: Option[String] = None, nazevKriteria: Option[String] = None ) extends Page( + "criteria", nazevKriteria.getOrElse("Detail kriteria"), Some( DetailParametru(osobniCislo, idParametru, jmenoOsoby, nazevParametru) @@ -69,12 +84,12 @@ ) } - case class NotFound(url: String) extends Page("404", Some(Directory)) + case class NotFound(url: String) extends Page("404", "404", Some(Directory)) case class UnhandledError( errorName: Option[String], errorMessage: Option[String] - ) extends Page("Unexpected error", Some(Directory)) + ) extends Page("500", "Unexpected error", Some(Directory)) object Routes: given JsonDecoder[OsobniCislo] = JsonDecoder.string.map(OsobniCislo.apply) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala index ea5600a..e77d2a3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -6,30 +6,25 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import com.raquo.waypoint.Router -class AppPage(using router: Router[Page]) - extends PageLayout - with PageHeader - with Breadcrumbs - with NavigationBar[Page] - with Navigator[Page]: +object AppPage: // TODO: pages by logged in user val pages = List(Page.Directory, Page.Dashboard) - override val logo = Logo( + import NavigationBar.{Logo, MenuItem} + + val logo = Logo( "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", "Workflow" ) // TODO: menu items by user profile - override val userMenu = + val userMenu = List( MenuItem("Your Profile"), MenuItem("Settings"), MenuItem("Sign out") ) - override def pageTitle(page: Page): String = page.title - // TODO: load user profile val $userProfile = Var( UserProfile( @@ -50,4 +45,23 @@ ) ) - override val $userInfo = $userProfile.signal.map(_.userInfo) + val $userInfo = $userProfile.signal.map(_.userInfo) + + type ViewModel = Option[HtmlElement] + def render($m: Signal[ViewModel], mods: Modifier[HtmlElement]*)(using + Router[Page] + ): HtmlElement = + PageLayout.render( + $m.combineWith($userInfo).map((c, u) => + PageLayout.ViewModel( + NavigationBar.ViewModel( + u, + pages, + userMenu, + logo + ), + c + ) + ), + mods + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala index 038d2d8..89134a5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala @@ -6,70 +6,12 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import cz.e_bs.cmi.mdr.pdb.app.Page -trait Breadcrumbs(using router: Router[Page]): - self: Navigator[Page] => +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.components.list.IconText.ViewModel - def breadcrumbs: HtmlElement = - val $p = router.$currentPage - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) +object Breadcrumbs: - private def renderFull(page: Page): HtmlElement = - div( - cls := "hidden sm:block", - ol( - role := "list", - cls := "flex items-center space-x-4", - renderItems(page) - ) - ) - - private def renderShort(page: Page): HtmlElement = - div( - cls := "flex sm:hidden", - page.parent match { - case None => renderHome(page) - case Some(p) => - a( - navigateTo(p), - cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", - Icons.solid.`arrow-narrow-left`, - span(p.title) - ) - } - ) - - private def renderItems(page: Page): Seq[HtmlElement] = - page.parent match { - case None => Seq(li(div(renderHome(page)))) - case Some(p) => - renderItems(p) :+ li( - div( - cls := "flex items-center", - slash, - a( - navigateTo(p), - cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", - p.title - ) - ) - ) - } - - private def renderHome(page: Page) = - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "text-gray-400 hover:text-gray-500", - Icons.solid.home, - span(cls := "sr-only", "Home") - ) - - private def slash = { + private def slash = import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", @@ -81,4 +23,68 @@ d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) - } + + object Home: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + a( + Navigator.navigateTo($m), + cls := "text-gray-400 hover:text-gray-500", + Icons.solid.home, + span(cls := "sr-only", "Home") + ) + + object Segment: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + li( + div( + cls := "flex items-center", + slash, + a( + Navigator.navigateTo($m), + cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", + child.text <-- $m.map(_.title) + ) + ) + ) + + object FullBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "hidden sm:block", + ol( + role := "list", + cls := "flex items-center space-x-4", + Home($m.map(_.path.head)), + children <-- $m.map(_.path.tail) + .split(_.id)((_, _, $p) => Segment($p)) + ) + ) + + object ShortBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "flex sm:hidden", + child <-- $m.map( + _.parent match + case None => Home($m) + case Some(p) => + a( + Navigator.navigateTo($m), + cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", + Icons.solid.`arrow-narrow-left`, + span(p.title) + ) + ) + ) + + def apply()(using router: Router[Page]): HtmlElement = + nav( + cls := "flex", + aria.label := "Breadcrumb", + ShortBreadcrumbs(router.$currentPage), + FullBreadcrumbs(router.$currentPage) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala index 0140851..f970506 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala @@ -12,17 +12,30 @@ import cz.e_bs.cmi.mdr.pdb.ParameterCriteria // enum is not working with Waypoints' SplitRender collectStatic -sealed abstract class Page(val title: String, val parent: Option[Page]) +sealed abstract class Page( + val id: String, + val title: String, + val parent: Option[Page] +) { + val path: Vector[Page] = + parent match + case None => Vector(this) + case Some(p) => p.path :+ this +} object Page: - case object Directory extends Page("Adresář", None) + case object Directory extends Page("directory", "Adresář", None) - case object Dashboard extends Page("Přehled", Some(Directory)) + case object Dashboard extends Page("dashboard", "Přehled", Some(Directory)) // TODO: refactor to some "NamedParameter" concept, where the tuples value + title are better managed case class Detail(osobniCislo: OsobniCislo, jmenoOsoby: Option[String] = None) - extends Page(jmenoOsoby.getOrElse("Detail osoby"), Some(Directory)) + extends Page( + "user", + jmenoOsoby.getOrElse("Detail osoby"), + Some(Directory) + ) object Detail { def apply(o: UserInfo): Detail = Detail(o.personalNumber, Some(o.name)) @@ -34,6 +47,7 @@ jmenoOsoby: Option[String] = None, nazevParametru: Option[String] = None ) extends Page( + "parameter", nazevParametru.getOrElse("Detail parametru"), Some(Detail(osobniCislo, jmenoOsoby)) ) @@ -51,6 +65,7 @@ nazevParametru: Option[String] = None, nazevKriteria: Option[String] = None ) extends Page( + "criteria", nazevKriteria.getOrElse("Detail kriteria"), Some( DetailParametru(osobniCislo, idParametru, jmenoOsoby, nazevParametru) @@ -69,12 +84,12 @@ ) } - case class NotFound(url: String) extends Page("404", Some(Directory)) + case class NotFound(url: String) extends Page("404", "404", Some(Directory)) case class UnhandledError( errorName: Option[String], errorMessage: Option[String] - ) extends Page("Unexpected error", Some(Directory)) + ) extends Page("500", "Unexpected error", Some(Directory)) object Routes: given JsonDecoder[OsobniCislo] = JsonDecoder.string.map(OsobniCislo.apply) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala index ea5600a..e77d2a3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -6,30 +6,25 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import com.raquo.waypoint.Router -class AppPage(using router: Router[Page]) - extends PageLayout - with PageHeader - with Breadcrumbs - with NavigationBar[Page] - with Navigator[Page]: +object AppPage: // TODO: pages by logged in user val pages = List(Page.Directory, Page.Dashboard) - override val logo = Logo( + import NavigationBar.{Logo, MenuItem} + + val logo = Logo( "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", "Workflow" ) // TODO: menu items by user profile - override val userMenu = + val userMenu = List( MenuItem("Your Profile"), MenuItem("Settings"), MenuItem("Sign out") ) - override def pageTitle(page: Page): String = page.title - // TODO: load user profile val $userProfile = Var( UserProfile( @@ -50,4 +45,23 @@ ) ) - override val $userInfo = $userProfile.signal.map(_.userInfo) + val $userInfo = $userProfile.signal.map(_.userInfo) + + type ViewModel = Option[HtmlElement] + def render($m: Signal[ViewModel], mods: Modifier[HtmlElement]*)(using + Router[Page] + ): HtmlElement = + PageLayout.render( + $m.combineWith($userInfo).map((c, u) => + PageLayout.ViewModel( + NavigationBar.ViewModel( + u, + pages, + userMenu, + logo + ), + c + ) + ), + mods + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala index 038d2d8..89134a5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala @@ -6,70 +6,12 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import cz.e_bs.cmi.mdr.pdb.app.Page -trait Breadcrumbs(using router: Router[Page]): - self: Navigator[Page] => +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.components.list.IconText.ViewModel - def breadcrumbs: HtmlElement = - val $p = router.$currentPage - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) +object Breadcrumbs: - private def renderFull(page: Page): HtmlElement = - div( - cls := "hidden sm:block", - ol( - role := "list", - cls := "flex items-center space-x-4", - renderItems(page) - ) - ) - - private def renderShort(page: Page): HtmlElement = - div( - cls := "flex sm:hidden", - page.parent match { - case None => renderHome(page) - case Some(p) => - a( - navigateTo(p), - cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", - Icons.solid.`arrow-narrow-left`, - span(p.title) - ) - } - ) - - private def renderItems(page: Page): Seq[HtmlElement] = - page.parent match { - case None => Seq(li(div(renderHome(page)))) - case Some(p) => - renderItems(p) :+ li( - div( - cls := "flex items-center", - slash, - a( - navigateTo(p), - cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", - p.title - ) - ) - ) - } - - private def renderHome(page: Page) = - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "text-gray-400 hover:text-gray-500", - Icons.solid.home, - span(cls := "sr-only", "Home") - ) - - private def slash = { + private def slash = import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", @@ -81,4 +23,68 @@ d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) - } + + object Home: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + a( + Navigator.navigateTo($m), + cls := "text-gray-400 hover:text-gray-500", + Icons.solid.home, + span(cls := "sr-only", "Home") + ) + + object Segment: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + li( + div( + cls := "flex items-center", + slash, + a( + Navigator.navigateTo($m), + cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", + child.text <-- $m.map(_.title) + ) + ) + ) + + object FullBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "hidden sm:block", + ol( + role := "list", + cls := "flex items-center space-x-4", + Home($m.map(_.path.head)), + children <-- $m.map(_.path.tail) + .split(_.id)((_, _, $p) => Segment($p)) + ) + ) + + object ShortBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "flex sm:hidden", + child <-- $m.map( + _.parent match + case None => Home($m) + case Some(p) => + a( + Navigator.navigateTo($m), + cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", + Icons.solid.`arrow-narrow-left`, + span(p.title) + ) + ) + ) + + def apply()(using router: Router[Page]): HtmlElement = + nav( + cls := "flex", + aria.label := "Breadcrumb", + ShortBreadcrumbs(router.$currentPage), + FullBreadcrumbs(router.$currentPage) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala index ea6c8bc..8ba5360 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -5,236 +5,239 @@ import CustomAttrs.ariaCurrent import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.UserInfo +import cz.e_bs.cmi.mdr.pdb.app.Page -trait NavigationBar[Page](using router: Router[Page]): - self: Navigator[Page] => +object NavigationBar: case class Logo(img: String, name: String) case class MenuItem(title: String) - def $userInfo: Signal[UserInfo] - - def pages: List[Page] - def userMenu: List[MenuItem] - def logo: Logo - - // Extract title from the page object - def pageTitle(page: Page): String - - def navigation: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - private val mobileMenuOpen = Var(false) - - private val desktopOnly = cls("hidden md:block") - private val mobileOnly = cls("md:hidden") - - private inline def avatarImage(size: Int = 8) = - Avatar($userInfo.map(_.img)).avatarImage(size) - - private def notificationButton = button( - tpe := "button", - cls := List( - "bg-indigo-600", - "focus:outline-none", - "focus:ring-2", - "focus:ring-offset-2", - "focus:ring-offset-indigo-600", - "focus:ring-white", - "hover:text-white", - "p-1", - "rounded-full", - "text-indigo-200" - ), - span(cls := "sr-only", "View notifications"), - Icons.outline.bell + case class ViewModel( + userInfo: UserInfo, + pages: List[Page], + userMenu: List[MenuItem], + logo: Logo ) - private def userProfile: HtmlElement = - val menuOpen = Var(false) + def render($m: Signal[ViewModel])(using router: Router[Page]): HtmlElement = + val $userInfo = $m.map(_.userInfo) + val mobileMenuOpen = Var(false) - def menuItem(item: MenuItem, idx: Int): HtmlElement = - a( - href := "#", - cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", - role := "menuitem", - tabIndex := -1, - idAttr := s"user-menu-item-$idx", - item.title - ) + val desktopOnly = cls("hidden md:block") + val mobileOnly = cls("md:hidden") - div( - cls := "ml-3 relative", - div( - button( - tpe := "button", - cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - idAttr := "user-menu-button", - aria.expanded <-- menuOpen.signal, - aria.hasPopup := true, - span(cls := "sr-only", "Open user menu"), - child <-- avatarImage(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) + inline def avatarImage(size: Int = 8) = + Avatar($userInfo.map(_.img)).avatarImage(size) + + def notificationButton = button( + tpe := "button", + cls := List( + "bg-indigo-600", + "focus:outline-none", + "focus:ring-2", + "focus:ring-offset-2", + "focus:ring-offset-indigo-600", + "focus:ring-white", + "hover:text-white", + "p-1", + "rounded-full", + "text-indigo-200" ), - /* - * */ - div( - cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", - cls <-- menuOpen.signal.map { o => - if (o) "md:block" else "md:hidden" - }, - role := "menu", - aria.orientation := "vertical", - aria.labelledBy := "user-menu-button", - tabIndex := -1, - // : keyboard navigation - userMenu.zipWithIndex.map(menuItem) - ) + span(cls := "sr-only", "View notifications"), + Icons.outline.bell ) - private def mobileProfile = - def menuItem(item: MenuItem): HtmlElement = - a( - href := "#", - cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", - item.title - ) + def userProfile: HtmlElement = + val menuOpen = Var(false) - div( - cls := "pt-4 pb-3 border-t border-indigo-700", + def menuItem(item: MenuItem, idx: Int): HtmlElement = + a( + href := "#", + cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", + role := "menuitem", + tabIndex := -1, + idAttr := s"user-menu-item-$idx", + item.title + ) + div( - cls := "flex items-center px-5", + cls := "ml-3 relative", div( - cls := "flex-shrink-0", - child <-- avatarImage(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- $userInfo.map(_.name) - ), - child.maybe <-- $userInfo.map( - _.email.map(e => - div( - cls := "text-sm font-medium text-indigo-300", - e - ) - ) + button( + tpe := "button", + cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + idAttr := "user-menu-button", + aria.expanded <-- menuOpen.signal, + aria.hasPopup := true, + span(cls := "sr-only", "Open user menu"), + child <-- avatarImage(), + onClick.preventDefault.mapTo( + !menuOpen.now() + ) --> menuOpen.writer ) ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - userMenu.map(menuItem) + /* + * */ + div( + cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", + cls <-- menuOpen.signal.map { o => + if (o) "md:block" else "md:hidden" + }, + role := "menu", + aria.orientation := "vertical", + aria.labelledBy := "user-menu-button", + tabIndex := -1, + // : keyboard navigation + children <-- $m.map(_.userMenu.zipWithIndex.map(menuItem)) + ) ) + + def mobileProfile = + def menuItem(item: MenuItem): HtmlElement = + a( + href := "#", + cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", + item.title + ) + + div( + cls := "pt-4 pb-3 border-t border-indigo-700", + div( + cls := "flex items-center px-5", + div( + cls := "flex-shrink-0", + child <-- avatarImage(10) + ), + div( + cls := "ml-3", + div( + cls := "text-base font-medium text-white", + child.text <-- $userInfo.map(_.name) + ), + child.maybe <-- $userInfo.map( + _.email.map(e => + div( + cls := "text-sm font-medium text-indigo-300", + e + ) + ) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + children <-- $m.map(_.userMenu.map(menuItem)) + ) + ) + + def pageLink(page: Page, active: Signal[Boolean]): Anchor = + a( + Navigator.navigateTo(page), + cls <-- active.map { + case true => "bg-indigo-700" + case false => "hover:bg-indigo-500 hover:bg-opacity-75" + }, + cls := "text-white px-3 py-2 rounded-md text-sm font-medium", + ariaCurrent <-- active.map { + case true => "page" + case _ => "false" + }, + page.title + ) + + def logoImg: Image = + img( + cls := "h-8 w-8", + src <-- $m.map(_.logo.img), + alt <-- $m.map(_.logo.name) + ) + + def pageLinks(mods: Modifier[HtmlElement]*) = + $m.map( + _.pages.map(p => + pageLink(p, router.$currentPage.map(p == _)).amend(mods) + ) + ) + + def mobileMenuButton = button( + tpe := "button", + cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + aria.controls := "mobile-menu", + aria.expanded <-- mobileMenuOpen.signal, + span(cls := "sr-only", "Open main menu"), + Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "hidden" else "block" + }), + Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }), + onClick.preventDefault.mapTo( + !mobileMenuOpen.now() + ) --> mobileMenuOpen.writer ) - private def pageLink(page: Page, active: Signal[Boolean]): Anchor = - a( - navigateTo(page), - cls <-- active.map { - case true => "bg-indigo-700" - case false => "hover:bg-indigo-500 hover:bg-opacity-75" - }, - cls := "text-white px-3 py-2 rounded-md text-sm font-medium", - ariaCurrent <-- active.map { - case true => "page" - case _ => "false" - }, - pageTitle(page) - ) + def navBarLeft = + div( + cls := "flex items-center", + div(cls := "flex-shrink-0", logoImg), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + children <-- pageLinks() + ) + ) + ) - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = - pages.map(p => pageLink(p, router.$currentPage.map(p == _)).amend(mods)) - - private def mobileMenuButton = button( - tpe := "button", - cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - aria.controls := "mobile-menu", - aria.expanded <-- mobileMenuOpen.signal, - span(cls := "sr-only", "Open main menu"), - Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "hidden" else "block" - }), - Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }), - onClick.preventDefault.mapTo( - !mobileMenuOpen.now() - ) --> mobileMenuOpen.writer - ) - - private def navBarLeft = - div( - cls := "flex items-center", - div(cls := "flex-shrink-0", logoImg), + def navBarRight = div( desktopOnly, div( - cls := "ml-10 flex items-baseline space-x-4", - pageLinks() + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile ) ) - ) - private def navBarRight = - div( - desktopOnly, + def navBarMobile = div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile + cls := "-mr-2 flex", + mobileOnly, + mobileMenuButton ) - ) - private def navBarMobile = - div( - cls := "-mr-2 flex", - mobileOnly, - mobileMenuButton - ) - - private def navBar = - div( - cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + def navBar = div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile + cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + div( + cls := "flex items-center justify-between h-16", + navBarLeft, + navBarRight, + navBarMobile + ) ) - ) - private def mobileMenu = - div( - mobileOnly, - cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }, - idAttr := "mobile-menu", + def mobileMenu = div( - cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", - pageLinks(cls := "block") - ), - mobileProfile - ) + mobileOnly, + cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }, + idAttr := "mobile-menu", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + children <-- pageLinks(cls := "block") + ), + mobileProfile + ) + + nav(cls := "bg-indigo-600", navBar, mobileMenu) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala index 0140851..f970506 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala @@ -12,17 +12,30 @@ import cz.e_bs.cmi.mdr.pdb.ParameterCriteria // enum is not working with Waypoints' SplitRender collectStatic -sealed abstract class Page(val title: String, val parent: Option[Page]) +sealed abstract class Page( + val id: String, + val title: String, + val parent: Option[Page] +) { + val path: Vector[Page] = + parent match + case None => Vector(this) + case Some(p) => p.path :+ this +} object Page: - case object Directory extends Page("Adresář", None) + case object Directory extends Page("directory", "Adresář", None) - case object Dashboard extends Page("Přehled", Some(Directory)) + case object Dashboard extends Page("dashboard", "Přehled", Some(Directory)) // TODO: refactor to some "NamedParameter" concept, where the tuples value + title are better managed case class Detail(osobniCislo: OsobniCislo, jmenoOsoby: Option[String] = None) - extends Page(jmenoOsoby.getOrElse("Detail osoby"), Some(Directory)) + extends Page( + "user", + jmenoOsoby.getOrElse("Detail osoby"), + Some(Directory) + ) object Detail { def apply(o: UserInfo): Detail = Detail(o.personalNumber, Some(o.name)) @@ -34,6 +47,7 @@ jmenoOsoby: Option[String] = None, nazevParametru: Option[String] = None ) extends Page( + "parameter", nazevParametru.getOrElse("Detail parametru"), Some(Detail(osobniCislo, jmenoOsoby)) ) @@ -51,6 +65,7 @@ nazevParametru: Option[String] = None, nazevKriteria: Option[String] = None ) extends Page( + "criteria", nazevKriteria.getOrElse("Detail kriteria"), Some( DetailParametru(osobniCislo, idParametru, jmenoOsoby, nazevParametru) @@ -69,12 +84,12 @@ ) } - case class NotFound(url: String) extends Page("404", Some(Directory)) + case class NotFound(url: String) extends Page("404", "404", Some(Directory)) case class UnhandledError( errorName: Option[String], errorMessage: Option[String] - ) extends Page("Unexpected error", Some(Directory)) + ) extends Page("500", "Unexpected error", Some(Directory)) object Routes: given JsonDecoder[OsobniCislo] = JsonDecoder.string.map(OsobniCislo.apply) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala index ea5600a..e77d2a3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -6,30 +6,25 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import com.raquo.waypoint.Router -class AppPage(using router: Router[Page]) - extends PageLayout - with PageHeader - with Breadcrumbs - with NavigationBar[Page] - with Navigator[Page]: +object AppPage: // TODO: pages by logged in user val pages = List(Page.Directory, Page.Dashboard) - override val logo = Logo( + import NavigationBar.{Logo, MenuItem} + + val logo = Logo( "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", "Workflow" ) // TODO: menu items by user profile - override val userMenu = + val userMenu = List( MenuItem("Your Profile"), MenuItem("Settings"), MenuItem("Sign out") ) - override def pageTitle(page: Page): String = page.title - // TODO: load user profile val $userProfile = Var( UserProfile( @@ -50,4 +45,23 @@ ) ) - override val $userInfo = $userProfile.signal.map(_.userInfo) + val $userInfo = $userProfile.signal.map(_.userInfo) + + type ViewModel = Option[HtmlElement] + def render($m: Signal[ViewModel], mods: Modifier[HtmlElement]*)(using + Router[Page] + ): HtmlElement = + PageLayout.render( + $m.combineWith($userInfo).map((c, u) => + PageLayout.ViewModel( + NavigationBar.ViewModel( + u, + pages, + userMenu, + logo + ), + c + ) + ), + mods + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala index 038d2d8..89134a5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala @@ -6,70 +6,12 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import cz.e_bs.cmi.mdr.pdb.app.Page -trait Breadcrumbs(using router: Router[Page]): - self: Navigator[Page] => +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.components.list.IconText.ViewModel - def breadcrumbs: HtmlElement = - val $p = router.$currentPage - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) +object Breadcrumbs: - private def renderFull(page: Page): HtmlElement = - div( - cls := "hidden sm:block", - ol( - role := "list", - cls := "flex items-center space-x-4", - renderItems(page) - ) - ) - - private def renderShort(page: Page): HtmlElement = - div( - cls := "flex sm:hidden", - page.parent match { - case None => renderHome(page) - case Some(p) => - a( - navigateTo(p), - cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", - Icons.solid.`arrow-narrow-left`, - span(p.title) - ) - } - ) - - private def renderItems(page: Page): Seq[HtmlElement] = - page.parent match { - case None => Seq(li(div(renderHome(page)))) - case Some(p) => - renderItems(p) :+ li( - div( - cls := "flex items-center", - slash, - a( - navigateTo(p), - cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", - p.title - ) - ) - ) - } - - private def renderHome(page: Page) = - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "text-gray-400 hover:text-gray-500", - Icons.solid.home, - span(cls := "sr-only", "Home") - ) - - private def slash = { + private def slash = import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", @@ -81,4 +23,68 @@ d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) - } + + object Home: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + a( + Navigator.navigateTo($m), + cls := "text-gray-400 hover:text-gray-500", + Icons.solid.home, + span(cls := "sr-only", "Home") + ) + + object Segment: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + li( + div( + cls := "flex items-center", + slash, + a( + Navigator.navigateTo($m), + cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", + child.text <-- $m.map(_.title) + ) + ) + ) + + object FullBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "hidden sm:block", + ol( + role := "list", + cls := "flex items-center space-x-4", + Home($m.map(_.path.head)), + children <-- $m.map(_.path.tail) + .split(_.id)((_, _, $p) => Segment($p)) + ) + ) + + object ShortBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "flex sm:hidden", + child <-- $m.map( + _.parent match + case None => Home($m) + case Some(p) => + a( + Navigator.navigateTo($m), + cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", + Icons.solid.`arrow-narrow-left`, + span(p.title) + ) + ) + ) + + def apply()(using router: Router[Page]): HtmlElement = + nav( + cls := "flex", + aria.label := "Breadcrumb", + ShortBreadcrumbs(router.$currentPage), + FullBreadcrumbs(router.$currentPage) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala index ea6c8bc..8ba5360 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -5,236 +5,239 @@ import CustomAttrs.ariaCurrent import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.UserInfo +import cz.e_bs.cmi.mdr.pdb.app.Page -trait NavigationBar[Page](using router: Router[Page]): - self: Navigator[Page] => +object NavigationBar: case class Logo(img: String, name: String) case class MenuItem(title: String) - def $userInfo: Signal[UserInfo] - - def pages: List[Page] - def userMenu: List[MenuItem] - def logo: Logo - - // Extract title from the page object - def pageTitle(page: Page): String - - def navigation: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - private val mobileMenuOpen = Var(false) - - private val desktopOnly = cls("hidden md:block") - private val mobileOnly = cls("md:hidden") - - private inline def avatarImage(size: Int = 8) = - Avatar($userInfo.map(_.img)).avatarImage(size) - - private def notificationButton = button( - tpe := "button", - cls := List( - "bg-indigo-600", - "focus:outline-none", - "focus:ring-2", - "focus:ring-offset-2", - "focus:ring-offset-indigo-600", - "focus:ring-white", - "hover:text-white", - "p-1", - "rounded-full", - "text-indigo-200" - ), - span(cls := "sr-only", "View notifications"), - Icons.outline.bell + case class ViewModel( + userInfo: UserInfo, + pages: List[Page], + userMenu: List[MenuItem], + logo: Logo ) - private def userProfile: HtmlElement = - val menuOpen = Var(false) + def render($m: Signal[ViewModel])(using router: Router[Page]): HtmlElement = + val $userInfo = $m.map(_.userInfo) + val mobileMenuOpen = Var(false) - def menuItem(item: MenuItem, idx: Int): HtmlElement = - a( - href := "#", - cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", - role := "menuitem", - tabIndex := -1, - idAttr := s"user-menu-item-$idx", - item.title - ) + val desktopOnly = cls("hidden md:block") + val mobileOnly = cls("md:hidden") - div( - cls := "ml-3 relative", - div( - button( - tpe := "button", - cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - idAttr := "user-menu-button", - aria.expanded <-- menuOpen.signal, - aria.hasPopup := true, - span(cls := "sr-only", "Open user menu"), - child <-- avatarImage(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) + inline def avatarImage(size: Int = 8) = + Avatar($userInfo.map(_.img)).avatarImage(size) + + def notificationButton = button( + tpe := "button", + cls := List( + "bg-indigo-600", + "focus:outline-none", + "focus:ring-2", + "focus:ring-offset-2", + "focus:ring-offset-indigo-600", + "focus:ring-white", + "hover:text-white", + "p-1", + "rounded-full", + "text-indigo-200" ), - /* - * */ - div( - cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", - cls <-- menuOpen.signal.map { o => - if (o) "md:block" else "md:hidden" - }, - role := "menu", - aria.orientation := "vertical", - aria.labelledBy := "user-menu-button", - tabIndex := -1, - // : keyboard navigation - userMenu.zipWithIndex.map(menuItem) - ) + span(cls := "sr-only", "View notifications"), + Icons.outline.bell ) - private def mobileProfile = - def menuItem(item: MenuItem): HtmlElement = - a( - href := "#", - cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", - item.title - ) + def userProfile: HtmlElement = + val menuOpen = Var(false) - div( - cls := "pt-4 pb-3 border-t border-indigo-700", + def menuItem(item: MenuItem, idx: Int): HtmlElement = + a( + href := "#", + cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", + role := "menuitem", + tabIndex := -1, + idAttr := s"user-menu-item-$idx", + item.title + ) + div( - cls := "flex items-center px-5", + cls := "ml-3 relative", div( - cls := "flex-shrink-0", - child <-- avatarImage(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- $userInfo.map(_.name) - ), - child.maybe <-- $userInfo.map( - _.email.map(e => - div( - cls := "text-sm font-medium text-indigo-300", - e - ) - ) + button( + tpe := "button", + cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + idAttr := "user-menu-button", + aria.expanded <-- menuOpen.signal, + aria.hasPopup := true, + span(cls := "sr-only", "Open user menu"), + child <-- avatarImage(), + onClick.preventDefault.mapTo( + !menuOpen.now() + ) --> menuOpen.writer ) ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - userMenu.map(menuItem) + /* + * */ + div( + cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", + cls <-- menuOpen.signal.map { o => + if (o) "md:block" else "md:hidden" + }, + role := "menu", + aria.orientation := "vertical", + aria.labelledBy := "user-menu-button", + tabIndex := -1, + // : keyboard navigation + children <-- $m.map(_.userMenu.zipWithIndex.map(menuItem)) + ) ) + + def mobileProfile = + def menuItem(item: MenuItem): HtmlElement = + a( + href := "#", + cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", + item.title + ) + + div( + cls := "pt-4 pb-3 border-t border-indigo-700", + div( + cls := "flex items-center px-5", + div( + cls := "flex-shrink-0", + child <-- avatarImage(10) + ), + div( + cls := "ml-3", + div( + cls := "text-base font-medium text-white", + child.text <-- $userInfo.map(_.name) + ), + child.maybe <-- $userInfo.map( + _.email.map(e => + div( + cls := "text-sm font-medium text-indigo-300", + e + ) + ) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + children <-- $m.map(_.userMenu.map(menuItem)) + ) + ) + + def pageLink(page: Page, active: Signal[Boolean]): Anchor = + a( + Navigator.navigateTo(page), + cls <-- active.map { + case true => "bg-indigo-700" + case false => "hover:bg-indigo-500 hover:bg-opacity-75" + }, + cls := "text-white px-3 py-2 rounded-md text-sm font-medium", + ariaCurrent <-- active.map { + case true => "page" + case _ => "false" + }, + page.title + ) + + def logoImg: Image = + img( + cls := "h-8 w-8", + src <-- $m.map(_.logo.img), + alt <-- $m.map(_.logo.name) + ) + + def pageLinks(mods: Modifier[HtmlElement]*) = + $m.map( + _.pages.map(p => + pageLink(p, router.$currentPage.map(p == _)).amend(mods) + ) + ) + + def mobileMenuButton = button( + tpe := "button", + cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + aria.controls := "mobile-menu", + aria.expanded <-- mobileMenuOpen.signal, + span(cls := "sr-only", "Open main menu"), + Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "hidden" else "block" + }), + Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }), + onClick.preventDefault.mapTo( + !mobileMenuOpen.now() + ) --> mobileMenuOpen.writer ) - private def pageLink(page: Page, active: Signal[Boolean]): Anchor = - a( - navigateTo(page), - cls <-- active.map { - case true => "bg-indigo-700" - case false => "hover:bg-indigo-500 hover:bg-opacity-75" - }, - cls := "text-white px-3 py-2 rounded-md text-sm font-medium", - ariaCurrent <-- active.map { - case true => "page" - case _ => "false" - }, - pageTitle(page) - ) + def navBarLeft = + div( + cls := "flex items-center", + div(cls := "flex-shrink-0", logoImg), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + children <-- pageLinks() + ) + ) + ) - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = - pages.map(p => pageLink(p, router.$currentPage.map(p == _)).amend(mods)) - - private def mobileMenuButton = button( - tpe := "button", - cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - aria.controls := "mobile-menu", - aria.expanded <-- mobileMenuOpen.signal, - span(cls := "sr-only", "Open main menu"), - Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "hidden" else "block" - }), - Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }), - onClick.preventDefault.mapTo( - !mobileMenuOpen.now() - ) --> mobileMenuOpen.writer - ) - - private def navBarLeft = - div( - cls := "flex items-center", - div(cls := "flex-shrink-0", logoImg), + def navBarRight = div( desktopOnly, div( - cls := "ml-10 flex items-baseline space-x-4", - pageLinks() + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile ) ) - ) - private def navBarRight = - div( - desktopOnly, + def navBarMobile = div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile + cls := "-mr-2 flex", + mobileOnly, + mobileMenuButton ) - ) - private def navBarMobile = - div( - cls := "-mr-2 flex", - mobileOnly, - mobileMenuButton - ) - - private def navBar = - div( - cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + def navBar = div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile + cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + div( + cls := "flex items-center justify-between h-16", + navBarLeft, + navBarRight, + navBarMobile + ) ) - ) - private def mobileMenu = - div( - mobileOnly, - cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }, - idAttr := "mobile-menu", + def mobileMenu = div( - cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", - pageLinks(cls := "block") - ), - mobileProfile - ) + mobileOnly, + cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }, + idAttr := "mobile-menu", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + children <-- pageLinks(cls := "block") + ), + mobileProfile + ) + + nav(cls := "bg-indigo-600", navBar, mobileMenu) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala index 122dfd2..e1b65e5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -2,19 +2,17 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.Page -import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.waypoint.Router -trait PageHeader: - self: Breadcrumbs with Navigator[Page] => - - def pageHeader: HtmlElement = +object PageHeader: + def render(using Router[Page]): HtmlElement = header( cls := "bg-white shadow-sm", div( cls := "max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8", h1( cls := "text-lg leading-6 font-semibold text-gray-900", - breadcrumbs + Breadcrumbs() ) ) ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala index 0140851..f970506 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala @@ -12,17 +12,30 @@ import cz.e_bs.cmi.mdr.pdb.ParameterCriteria // enum is not working with Waypoints' SplitRender collectStatic -sealed abstract class Page(val title: String, val parent: Option[Page]) +sealed abstract class Page( + val id: String, + val title: String, + val parent: Option[Page] +) { + val path: Vector[Page] = + parent match + case None => Vector(this) + case Some(p) => p.path :+ this +} object Page: - case object Directory extends Page("Adresář", None) + case object Directory extends Page("directory", "Adresář", None) - case object Dashboard extends Page("Přehled", Some(Directory)) + case object Dashboard extends Page("dashboard", "Přehled", Some(Directory)) // TODO: refactor to some "NamedParameter" concept, where the tuples value + title are better managed case class Detail(osobniCislo: OsobniCislo, jmenoOsoby: Option[String] = None) - extends Page(jmenoOsoby.getOrElse("Detail osoby"), Some(Directory)) + extends Page( + "user", + jmenoOsoby.getOrElse("Detail osoby"), + Some(Directory) + ) object Detail { def apply(o: UserInfo): Detail = Detail(o.personalNumber, Some(o.name)) @@ -34,6 +47,7 @@ jmenoOsoby: Option[String] = None, nazevParametru: Option[String] = None ) extends Page( + "parameter", nazevParametru.getOrElse("Detail parametru"), Some(Detail(osobniCislo, jmenoOsoby)) ) @@ -51,6 +65,7 @@ nazevParametru: Option[String] = None, nazevKriteria: Option[String] = None ) extends Page( + "criteria", nazevKriteria.getOrElse("Detail kriteria"), Some( DetailParametru(osobniCislo, idParametru, jmenoOsoby, nazevParametru) @@ -69,12 +84,12 @@ ) } - case class NotFound(url: String) extends Page("404", Some(Directory)) + case class NotFound(url: String) extends Page("404", "404", Some(Directory)) case class UnhandledError( errorName: Option[String], errorMessage: Option[String] - ) extends Page("Unexpected error", Some(Directory)) + ) extends Page("500", "Unexpected error", Some(Directory)) object Routes: given JsonDecoder[OsobniCislo] = JsonDecoder.string.map(OsobniCislo.apply) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala index ea5600a..e77d2a3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -6,30 +6,25 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import com.raquo.waypoint.Router -class AppPage(using router: Router[Page]) - extends PageLayout - with PageHeader - with Breadcrumbs - with NavigationBar[Page] - with Navigator[Page]: +object AppPage: // TODO: pages by logged in user val pages = List(Page.Directory, Page.Dashboard) - override val logo = Logo( + import NavigationBar.{Logo, MenuItem} + + val logo = Logo( "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", "Workflow" ) // TODO: menu items by user profile - override val userMenu = + val userMenu = List( MenuItem("Your Profile"), MenuItem("Settings"), MenuItem("Sign out") ) - override def pageTitle(page: Page): String = page.title - // TODO: load user profile val $userProfile = Var( UserProfile( @@ -50,4 +45,23 @@ ) ) - override val $userInfo = $userProfile.signal.map(_.userInfo) + val $userInfo = $userProfile.signal.map(_.userInfo) + + type ViewModel = Option[HtmlElement] + def render($m: Signal[ViewModel], mods: Modifier[HtmlElement]*)(using + Router[Page] + ): HtmlElement = + PageLayout.render( + $m.combineWith($userInfo).map((c, u) => + PageLayout.ViewModel( + NavigationBar.ViewModel( + u, + pages, + userMenu, + logo + ), + c + ) + ), + mods + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala index 038d2d8..89134a5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala @@ -6,70 +6,12 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import cz.e_bs.cmi.mdr.pdb.app.Page -trait Breadcrumbs(using router: Router[Page]): - self: Navigator[Page] => +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.components.list.IconText.ViewModel - def breadcrumbs: HtmlElement = - val $p = router.$currentPage - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) +object Breadcrumbs: - private def renderFull(page: Page): HtmlElement = - div( - cls := "hidden sm:block", - ol( - role := "list", - cls := "flex items-center space-x-4", - renderItems(page) - ) - ) - - private def renderShort(page: Page): HtmlElement = - div( - cls := "flex sm:hidden", - page.parent match { - case None => renderHome(page) - case Some(p) => - a( - navigateTo(p), - cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", - Icons.solid.`arrow-narrow-left`, - span(p.title) - ) - } - ) - - private def renderItems(page: Page): Seq[HtmlElement] = - page.parent match { - case None => Seq(li(div(renderHome(page)))) - case Some(p) => - renderItems(p) :+ li( - div( - cls := "flex items-center", - slash, - a( - navigateTo(p), - cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", - p.title - ) - ) - ) - } - - private def renderHome(page: Page) = - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "text-gray-400 hover:text-gray-500", - Icons.solid.home, - span(cls := "sr-only", "Home") - ) - - private def slash = { + private def slash = import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", @@ -81,4 +23,68 @@ d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) - } + + object Home: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + a( + Navigator.navigateTo($m), + cls := "text-gray-400 hover:text-gray-500", + Icons.solid.home, + span(cls := "sr-only", "Home") + ) + + object Segment: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + li( + div( + cls := "flex items-center", + slash, + a( + Navigator.navigateTo($m), + cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", + child.text <-- $m.map(_.title) + ) + ) + ) + + object FullBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "hidden sm:block", + ol( + role := "list", + cls := "flex items-center space-x-4", + Home($m.map(_.path.head)), + children <-- $m.map(_.path.tail) + .split(_.id)((_, _, $p) => Segment($p)) + ) + ) + + object ShortBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "flex sm:hidden", + child <-- $m.map( + _.parent match + case None => Home($m) + case Some(p) => + a( + Navigator.navigateTo($m), + cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", + Icons.solid.`arrow-narrow-left`, + span(p.title) + ) + ) + ) + + def apply()(using router: Router[Page]): HtmlElement = + nav( + cls := "flex", + aria.label := "Breadcrumb", + ShortBreadcrumbs(router.$currentPage), + FullBreadcrumbs(router.$currentPage) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala index ea6c8bc..8ba5360 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -5,236 +5,239 @@ import CustomAttrs.ariaCurrent import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.UserInfo +import cz.e_bs.cmi.mdr.pdb.app.Page -trait NavigationBar[Page](using router: Router[Page]): - self: Navigator[Page] => +object NavigationBar: case class Logo(img: String, name: String) case class MenuItem(title: String) - def $userInfo: Signal[UserInfo] - - def pages: List[Page] - def userMenu: List[MenuItem] - def logo: Logo - - // Extract title from the page object - def pageTitle(page: Page): String - - def navigation: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - private val mobileMenuOpen = Var(false) - - private val desktopOnly = cls("hidden md:block") - private val mobileOnly = cls("md:hidden") - - private inline def avatarImage(size: Int = 8) = - Avatar($userInfo.map(_.img)).avatarImage(size) - - private def notificationButton = button( - tpe := "button", - cls := List( - "bg-indigo-600", - "focus:outline-none", - "focus:ring-2", - "focus:ring-offset-2", - "focus:ring-offset-indigo-600", - "focus:ring-white", - "hover:text-white", - "p-1", - "rounded-full", - "text-indigo-200" - ), - span(cls := "sr-only", "View notifications"), - Icons.outline.bell + case class ViewModel( + userInfo: UserInfo, + pages: List[Page], + userMenu: List[MenuItem], + logo: Logo ) - private def userProfile: HtmlElement = - val menuOpen = Var(false) + def render($m: Signal[ViewModel])(using router: Router[Page]): HtmlElement = + val $userInfo = $m.map(_.userInfo) + val mobileMenuOpen = Var(false) - def menuItem(item: MenuItem, idx: Int): HtmlElement = - a( - href := "#", - cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", - role := "menuitem", - tabIndex := -1, - idAttr := s"user-menu-item-$idx", - item.title - ) + val desktopOnly = cls("hidden md:block") + val mobileOnly = cls("md:hidden") - div( - cls := "ml-3 relative", - div( - button( - tpe := "button", - cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - idAttr := "user-menu-button", - aria.expanded <-- menuOpen.signal, - aria.hasPopup := true, - span(cls := "sr-only", "Open user menu"), - child <-- avatarImage(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) + inline def avatarImage(size: Int = 8) = + Avatar($userInfo.map(_.img)).avatarImage(size) + + def notificationButton = button( + tpe := "button", + cls := List( + "bg-indigo-600", + "focus:outline-none", + "focus:ring-2", + "focus:ring-offset-2", + "focus:ring-offset-indigo-600", + "focus:ring-white", + "hover:text-white", + "p-1", + "rounded-full", + "text-indigo-200" ), - /* - * */ - div( - cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", - cls <-- menuOpen.signal.map { o => - if (o) "md:block" else "md:hidden" - }, - role := "menu", - aria.orientation := "vertical", - aria.labelledBy := "user-menu-button", - tabIndex := -1, - // : keyboard navigation - userMenu.zipWithIndex.map(menuItem) - ) + span(cls := "sr-only", "View notifications"), + Icons.outline.bell ) - private def mobileProfile = - def menuItem(item: MenuItem): HtmlElement = - a( - href := "#", - cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", - item.title - ) + def userProfile: HtmlElement = + val menuOpen = Var(false) - div( - cls := "pt-4 pb-3 border-t border-indigo-700", + def menuItem(item: MenuItem, idx: Int): HtmlElement = + a( + href := "#", + cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", + role := "menuitem", + tabIndex := -1, + idAttr := s"user-menu-item-$idx", + item.title + ) + div( - cls := "flex items-center px-5", + cls := "ml-3 relative", div( - cls := "flex-shrink-0", - child <-- avatarImage(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- $userInfo.map(_.name) - ), - child.maybe <-- $userInfo.map( - _.email.map(e => - div( - cls := "text-sm font-medium text-indigo-300", - e - ) - ) + button( + tpe := "button", + cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + idAttr := "user-menu-button", + aria.expanded <-- menuOpen.signal, + aria.hasPopup := true, + span(cls := "sr-only", "Open user menu"), + child <-- avatarImage(), + onClick.preventDefault.mapTo( + !menuOpen.now() + ) --> menuOpen.writer ) ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - userMenu.map(menuItem) + /* + * */ + div( + cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", + cls <-- menuOpen.signal.map { o => + if (o) "md:block" else "md:hidden" + }, + role := "menu", + aria.orientation := "vertical", + aria.labelledBy := "user-menu-button", + tabIndex := -1, + // : keyboard navigation + children <-- $m.map(_.userMenu.zipWithIndex.map(menuItem)) + ) ) + + def mobileProfile = + def menuItem(item: MenuItem): HtmlElement = + a( + href := "#", + cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", + item.title + ) + + div( + cls := "pt-4 pb-3 border-t border-indigo-700", + div( + cls := "flex items-center px-5", + div( + cls := "flex-shrink-0", + child <-- avatarImage(10) + ), + div( + cls := "ml-3", + div( + cls := "text-base font-medium text-white", + child.text <-- $userInfo.map(_.name) + ), + child.maybe <-- $userInfo.map( + _.email.map(e => + div( + cls := "text-sm font-medium text-indigo-300", + e + ) + ) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + children <-- $m.map(_.userMenu.map(menuItem)) + ) + ) + + def pageLink(page: Page, active: Signal[Boolean]): Anchor = + a( + Navigator.navigateTo(page), + cls <-- active.map { + case true => "bg-indigo-700" + case false => "hover:bg-indigo-500 hover:bg-opacity-75" + }, + cls := "text-white px-3 py-2 rounded-md text-sm font-medium", + ariaCurrent <-- active.map { + case true => "page" + case _ => "false" + }, + page.title + ) + + def logoImg: Image = + img( + cls := "h-8 w-8", + src <-- $m.map(_.logo.img), + alt <-- $m.map(_.logo.name) + ) + + def pageLinks(mods: Modifier[HtmlElement]*) = + $m.map( + _.pages.map(p => + pageLink(p, router.$currentPage.map(p == _)).amend(mods) + ) + ) + + def mobileMenuButton = button( + tpe := "button", + cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + aria.controls := "mobile-menu", + aria.expanded <-- mobileMenuOpen.signal, + span(cls := "sr-only", "Open main menu"), + Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "hidden" else "block" + }), + Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }), + onClick.preventDefault.mapTo( + !mobileMenuOpen.now() + ) --> mobileMenuOpen.writer ) - private def pageLink(page: Page, active: Signal[Boolean]): Anchor = - a( - navigateTo(page), - cls <-- active.map { - case true => "bg-indigo-700" - case false => "hover:bg-indigo-500 hover:bg-opacity-75" - }, - cls := "text-white px-3 py-2 rounded-md text-sm font-medium", - ariaCurrent <-- active.map { - case true => "page" - case _ => "false" - }, - pageTitle(page) - ) + def navBarLeft = + div( + cls := "flex items-center", + div(cls := "flex-shrink-0", logoImg), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + children <-- pageLinks() + ) + ) + ) - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = - pages.map(p => pageLink(p, router.$currentPage.map(p == _)).amend(mods)) - - private def mobileMenuButton = button( - tpe := "button", - cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - aria.controls := "mobile-menu", - aria.expanded <-- mobileMenuOpen.signal, - span(cls := "sr-only", "Open main menu"), - Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "hidden" else "block" - }), - Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }), - onClick.preventDefault.mapTo( - !mobileMenuOpen.now() - ) --> mobileMenuOpen.writer - ) - - private def navBarLeft = - div( - cls := "flex items-center", - div(cls := "flex-shrink-0", logoImg), + def navBarRight = div( desktopOnly, div( - cls := "ml-10 flex items-baseline space-x-4", - pageLinks() + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile ) ) - ) - private def navBarRight = - div( - desktopOnly, + def navBarMobile = div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile + cls := "-mr-2 flex", + mobileOnly, + mobileMenuButton ) - ) - private def navBarMobile = - div( - cls := "-mr-2 flex", - mobileOnly, - mobileMenuButton - ) - - private def navBar = - div( - cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + def navBar = div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile + cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + div( + cls := "flex items-center justify-between h-16", + navBarLeft, + navBarRight, + navBarMobile + ) ) - ) - private def mobileMenu = - div( - mobileOnly, - cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }, - idAttr := "mobile-menu", + def mobileMenu = div( - cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", - pageLinks(cls := "block") - ), - mobileProfile - ) + mobileOnly, + cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }, + idAttr := "mobile-menu", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + children <-- pageLinks(cls := "block") + ), + mobileProfile + ) + + nav(cls := "bg-indigo-600", navBar, mobileMenu) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala index 122dfd2..e1b65e5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -2,19 +2,17 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.Page -import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.waypoint.Router -trait PageHeader: - self: Breadcrumbs with Navigator[Page] => - - def pageHeader: HtmlElement = +object PageHeader: + def render(using Router[Page]): HtmlElement = header( cls := "bg-white shadow-sm", div( cls := "max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8", h1( cls := "text-lg leading-6 font-semibold text-gray-900", - breadcrumbs + Breadcrumbs() ) ) ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala index 006efa0..3ee6483 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -1,23 +1,25 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.Page -trait PageLayout { - def navigation: HtmlElement - def pageHeader: HtmlElement - +object PageLayout: + case class ViewModel( + navigation: NavigationBar.ViewModel, + content: Option[HtmlElement] + ) def render( - $m: Signal[Option[HtmlElement]], + $m: Signal[ViewModel], mods: Modifier[HtmlElement]* - ): HtmlElement = - val $maybeContent = $m.split(_ => ())((_, c, _) => c) + )(using Router[Page]): HtmlElement = + val $maybeContent = $m.map(_.content).split(_ => ())((_, c, _) => c) div( cls := "min-h-full", - navigation, - pageHeader, + NavigationBar.render($m.map(_.navigation)), + PageHeader.render, main( mods, child <-- $maybeContent.map(_.getOrElse(Loading)) ) ) -} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala index 0140851..f970506 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala @@ -12,17 +12,30 @@ import cz.e_bs.cmi.mdr.pdb.ParameterCriteria // enum is not working with Waypoints' SplitRender collectStatic -sealed abstract class Page(val title: String, val parent: Option[Page]) +sealed abstract class Page( + val id: String, + val title: String, + val parent: Option[Page] +) { + val path: Vector[Page] = + parent match + case None => Vector(this) + case Some(p) => p.path :+ this +} object Page: - case object Directory extends Page("Adresář", None) + case object Directory extends Page("directory", "Adresář", None) - case object Dashboard extends Page("Přehled", Some(Directory)) + case object Dashboard extends Page("dashboard", "Přehled", Some(Directory)) // TODO: refactor to some "NamedParameter" concept, where the tuples value + title are better managed case class Detail(osobniCislo: OsobniCislo, jmenoOsoby: Option[String] = None) - extends Page(jmenoOsoby.getOrElse("Detail osoby"), Some(Directory)) + extends Page( + "user", + jmenoOsoby.getOrElse("Detail osoby"), + Some(Directory) + ) object Detail { def apply(o: UserInfo): Detail = Detail(o.personalNumber, Some(o.name)) @@ -34,6 +47,7 @@ jmenoOsoby: Option[String] = None, nazevParametru: Option[String] = None ) extends Page( + "parameter", nazevParametru.getOrElse("Detail parametru"), Some(Detail(osobniCislo, jmenoOsoby)) ) @@ -51,6 +65,7 @@ nazevParametru: Option[String] = None, nazevKriteria: Option[String] = None ) extends Page( + "criteria", nazevKriteria.getOrElse("Detail kriteria"), Some( DetailParametru(osobniCislo, idParametru, jmenoOsoby, nazevParametru) @@ -69,12 +84,12 @@ ) } - case class NotFound(url: String) extends Page("404", Some(Directory)) + case class NotFound(url: String) extends Page("404", "404", Some(Directory)) case class UnhandledError( errorName: Option[String], errorMessage: Option[String] - ) extends Page("Unexpected error", Some(Directory)) + ) extends Page("500", "Unexpected error", Some(Directory)) object Routes: given JsonDecoder[OsobniCislo] = JsonDecoder.string.map(OsobniCislo.apply) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala index ea5600a..e77d2a3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -6,30 +6,25 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import com.raquo.waypoint.Router -class AppPage(using router: Router[Page]) - extends PageLayout - with PageHeader - with Breadcrumbs - with NavigationBar[Page] - with Navigator[Page]: +object AppPage: // TODO: pages by logged in user val pages = List(Page.Directory, Page.Dashboard) - override val logo = Logo( + import NavigationBar.{Logo, MenuItem} + + val logo = Logo( "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", "Workflow" ) // TODO: menu items by user profile - override val userMenu = + val userMenu = List( MenuItem("Your Profile"), MenuItem("Settings"), MenuItem("Sign out") ) - override def pageTitle(page: Page): String = page.title - // TODO: load user profile val $userProfile = Var( UserProfile( @@ -50,4 +45,23 @@ ) ) - override val $userInfo = $userProfile.signal.map(_.userInfo) + val $userInfo = $userProfile.signal.map(_.userInfo) + + type ViewModel = Option[HtmlElement] + def render($m: Signal[ViewModel], mods: Modifier[HtmlElement]*)(using + Router[Page] + ): HtmlElement = + PageLayout.render( + $m.combineWith($userInfo).map((c, u) => + PageLayout.ViewModel( + NavigationBar.ViewModel( + u, + pages, + userMenu, + logo + ), + c + ) + ), + mods + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala index 038d2d8..89134a5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala @@ -6,70 +6,12 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import cz.e_bs.cmi.mdr.pdb.app.Page -trait Breadcrumbs(using router: Router[Page]): - self: Navigator[Page] => +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.components.list.IconText.ViewModel - def breadcrumbs: HtmlElement = - val $p = router.$currentPage - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) +object Breadcrumbs: - private def renderFull(page: Page): HtmlElement = - div( - cls := "hidden sm:block", - ol( - role := "list", - cls := "flex items-center space-x-4", - renderItems(page) - ) - ) - - private def renderShort(page: Page): HtmlElement = - div( - cls := "flex sm:hidden", - page.parent match { - case None => renderHome(page) - case Some(p) => - a( - navigateTo(p), - cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", - Icons.solid.`arrow-narrow-left`, - span(p.title) - ) - } - ) - - private def renderItems(page: Page): Seq[HtmlElement] = - page.parent match { - case None => Seq(li(div(renderHome(page)))) - case Some(p) => - renderItems(p) :+ li( - div( - cls := "flex items-center", - slash, - a( - navigateTo(p), - cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", - p.title - ) - ) - ) - } - - private def renderHome(page: Page) = - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "text-gray-400 hover:text-gray-500", - Icons.solid.home, - span(cls := "sr-only", "Home") - ) - - private def slash = { + private def slash = import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", @@ -81,4 +23,68 @@ d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) - } + + object Home: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + a( + Navigator.navigateTo($m), + cls := "text-gray-400 hover:text-gray-500", + Icons.solid.home, + span(cls := "sr-only", "Home") + ) + + object Segment: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + li( + div( + cls := "flex items-center", + slash, + a( + Navigator.navigateTo($m), + cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", + child.text <-- $m.map(_.title) + ) + ) + ) + + object FullBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "hidden sm:block", + ol( + role := "list", + cls := "flex items-center space-x-4", + Home($m.map(_.path.head)), + children <-- $m.map(_.path.tail) + .split(_.id)((_, _, $p) => Segment($p)) + ) + ) + + object ShortBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "flex sm:hidden", + child <-- $m.map( + _.parent match + case None => Home($m) + case Some(p) => + a( + Navigator.navigateTo($m), + cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", + Icons.solid.`arrow-narrow-left`, + span(p.title) + ) + ) + ) + + def apply()(using router: Router[Page]): HtmlElement = + nav( + cls := "flex", + aria.label := "Breadcrumb", + ShortBreadcrumbs(router.$currentPage), + FullBreadcrumbs(router.$currentPage) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala index ea6c8bc..8ba5360 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -5,236 +5,239 @@ import CustomAttrs.ariaCurrent import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.UserInfo +import cz.e_bs.cmi.mdr.pdb.app.Page -trait NavigationBar[Page](using router: Router[Page]): - self: Navigator[Page] => +object NavigationBar: case class Logo(img: String, name: String) case class MenuItem(title: String) - def $userInfo: Signal[UserInfo] - - def pages: List[Page] - def userMenu: List[MenuItem] - def logo: Logo - - // Extract title from the page object - def pageTitle(page: Page): String - - def navigation: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - private val mobileMenuOpen = Var(false) - - private val desktopOnly = cls("hidden md:block") - private val mobileOnly = cls("md:hidden") - - private inline def avatarImage(size: Int = 8) = - Avatar($userInfo.map(_.img)).avatarImage(size) - - private def notificationButton = button( - tpe := "button", - cls := List( - "bg-indigo-600", - "focus:outline-none", - "focus:ring-2", - "focus:ring-offset-2", - "focus:ring-offset-indigo-600", - "focus:ring-white", - "hover:text-white", - "p-1", - "rounded-full", - "text-indigo-200" - ), - span(cls := "sr-only", "View notifications"), - Icons.outline.bell + case class ViewModel( + userInfo: UserInfo, + pages: List[Page], + userMenu: List[MenuItem], + logo: Logo ) - private def userProfile: HtmlElement = - val menuOpen = Var(false) + def render($m: Signal[ViewModel])(using router: Router[Page]): HtmlElement = + val $userInfo = $m.map(_.userInfo) + val mobileMenuOpen = Var(false) - def menuItem(item: MenuItem, idx: Int): HtmlElement = - a( - href := "#", - cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", - role := "menuitem", - tabIndex := -1, - idAttr := s"user-menu-item-$idx", - item.title - ) + val desktopOnly = cls("hidden md:block") + val mobileOnly = cls("md:hidden") - div( - cls := "ml-3 relative", - div( - button( - tpe := "button", - cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - idAttr := "user-menu-button", - aria.expanded <-- menuOpen.signal, - aria.hasPopup := true, - span(cls := "sr-only", "Open user menu"), - child <-- avatarImage(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) + inline def avatarImage(size: Int = 8) = + Avatar($userInfo.map(_.img)).avatarImage(size) + + def notificationButton = button( + tpe := "button", + cls := List( + "bg-indigo-600", + "focus:outline-none", + "focus:ring-2", + "focus:ring-offset-2", + "focus:ring-offset-indigo-600", + "focus:ring-white", + "hover:text-white", + "p-1", + "rounded-full", + "text-indigo-200" ), - /* - * */ - div( - cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", - cls <-- menuOpen.signal.map { o => - if (o) "md:block" else "md:hidden" - }, - role := "menu", - aria.orientation := "vertical", - aria.labelledBy := "user-menu-button", - tabIndex := -1, - // : keyboard navigation - userMenu.zipWithIndex.map(menuItem) - ) + span(cls := "sr-only", "View notifications"), + Icons.outline.bell ) - private def mobileProfile = - def menuItem(item: MenuItem): HtmlElement = - a( - href := "#", - cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", - item.title - ) + def userProfile: HtmlElement = + val menuOpen = Var(false) - div( - cls := "pt-4 pb-3 border-t border-indigo-700", + def menuItem(item: MenuItem, idx: Int): HtmlElement = + a( + href := "#", + cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", + role := "menuitem", + tabIndex := -1, + idAttr := s"user-menu-item-$idx", + item.title + ) + div( - cls := "flex items-center px-5", + cls := "ml-3 relative", div( - cls := "flex-shrink-0", - child <-- avatarImage(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- $userInfo.map(_.name) - ), - child.maybe <-- $userInfo.map( - _.email.map(e => - div( - cls := "text-sm font-medium text-indigo-300", - e - ) - ) + button( + tpe := "button", + cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + idAttr := "user-menu-button", + aria.expanded <-- menuOpen.signal, + aria.hasPopup := true, + span(cls := "sr-only", "Open user menu"), + child <-- avatarImage(), + onClick.preventDefault.mapTo( + !menuOpen.now() + ) --> menuOpen.writer ) ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - userMenu.map(menuItem) + /* + * */ + div( + cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", + cls <-- menuOpen.signal.map { o => + if (o) "md:block" else "md:hidden" + }, + role := "menu", + aria.orientation := "vertical", + aria.labelledBy := "user-menu-button", + tabIndex := -1, + // : keyboard navigation + children <-- $m.map(_.userMenu.zipWithIndex.map(menuItem)) + ) ) + + def mobileProfile = + def menuItem(item: MenuItem): HtmlElement = + a( + href := "#", + cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", + item.title + ) + + div( + cls := "pt-4 pb-3 border-t border-indigo-700", + div( + cls := "flex items-center px-5", + div( + cls := "flex-shrink-0", + child <-- avatarImage(10) + ), + div( + cls := "ml-3", + div( + cls := "text-base font-medium text-white", + child.text <-- $userInfo.map(_.name) + ), + child.maybe <-- $userInfo.map( + _.email.map(e => + div( + cls := "text-sm font-medium text-indigo-300", + e + ) + ) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + children <-- $m.map(_.userMenu.map(menuItem)) + ) + ) + + def pageLink(page: Page, active: Signal[Boolean]): Anchor = + a( + Navigator.navigateTo(page), + cls <-- active.map { + case true => "bg-indigo-700" + case false => "hover:bg-indigo-500 hover:bg-opacity-75" + }, + cls := "text-white px-3 py-2 rounded-md text-sm font-medium", + ariaCurrent <-- active.map { + case true => "page" + case _ => "false" + }, + page.title + ) + + def logoImg: Image = + img( + cls := "h-8 w-8", + src <-- $m.map(_.logo.img), + alt <-- $m.map(_.logo.name) + ) + + def pageLinks(mods: Modifier[HtmlElement]*) = + $m.map( + _.pages.map(p => + pageLink(p, router.$currentPage.map(p == _)).amend(mods) + ) + ) + + def mobileMenuButton = button( + tpe := "button", + cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + aria.controls := "mobile-menu", + aria.expanded <-- mobileMenuOpen.signal, + span(cls := "sr-only", "Open main menu"), + Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "hidden" else "block" + }), + Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }), + onClick.preventDefault.mapTo( + !mobileMenuOpen.now() + ) --> mobileMenuOpen.writer ) - private def pageLink(page: Page, active: Signal[Boolean]): Anchor = - a( - navigateTo(page), - cls <-- active.map { - case true => "bg-indigo-700" - case false => "hover:bg-indigo-500 hover:bg-opacity-75" - }, - cls := "text-white px-3 py-2 rounded-md text-sm font-medium", - ariaCurrent <-- active.map { - case true => "page" - case _ => "false" - }, - pageTitle(page) - ) + def navBarLeft = + div( + cls := "flex items-center", + div(cls := "flex-shrink-0", logoImg), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + children <-- pageLinks() + ) + ) + ) - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = - pages.map(p => pageLink(p, router.$currentPage.map(p == _)).amend(mods)) - - private def mobileMenuButton = button( - tpe := "button", - cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - aria.controls := "mobile-menu", - aria.expanded <-- mobileMenuOpen.signal, - span(cls := "sr-only", "Open main menu"), - Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "hidden" else "block" - }), - Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }), - onClick.preventDefault.mapTo( - !mobileMenuOpen.now() - ) --> mobileMenuOpen.writer - ) - - private def navBarLeft = - div( - cls := "flex items-center", - div(cls := "flex-shrink-0", logoImg), + def navBarRight = div( desktopOnly, div( - cls := "ml-10 flex items-baseline space-x-4", - pageLinks() + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile ) ) - ) - private def navBarRight = - div( - desktopOnly, + def navBarMobile = div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile + cls := "-mr-2 flex", + mobileOnly, + mobileMenuButton ) - ) - private def navBarMobile = - div( - cls := "-mr-2 flex", - mobileOnly, - mobileMenuButton - ) - - private def navBar = - div( - cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + def navBar = div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile + cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + div( + cls := "flex items-center justify-between h-16", + navBarLeft, + navBarRight, + navBarMobile + ) ) - ) - private def mobileMenu = - div( - mobileOnly, - cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }, - idAttr := "mobile-menu", + def mobileMenu = div( - cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", - pageLinks(cls := "block") - ), - mobileProfile - ) + mobileOnly, + cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }, + idAttr := "mobile-menu", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + children <-- pageLinks(cls := "block") + ), + mobileProfile + ) + + nav(cls := "bg-indigo-600", navBar, mobileMenu) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala index 122dfd2..e1b65e5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -2,19 +2,17 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.Page -import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.waypoint.Router -trait PageHeader: - self: Breadcrumbs with Navigator[Page] => - - def pageHeader: HtmlElement = +object PageHeader: + def render(using Router[Page]): HtmlElement = header( cls := "bg-white shadow-sm", div( cls := "max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8", h1( cls := "text-lg leading-6 font-semibold text-gray-900", - breadcrumbs + Breadcrumbs() ) ) ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala index 006efa0..3ee6483 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -1,23 +1,25 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.Page -trait PageLayout { - def navigation: HtmlElement - def pageHeader: HtmlElement - +object PageLayout: + case class ViewModel( + navigation: NavigationBar.ViewModel, + content: Option[HtmlElement] + ) def render( - $m: Signal[Option[HtmlElement]], + $m: Signal[ViewModel], mods: Modifier[HtmlElement]* - ): HtmlElement = - val $maybeContent = $m.split(_ => ())((_, c, _) => c) + )(using Router[Page]): HtmlElement = + val $maybeContent = $m.map(_.content).split(_ => ())((_, c, _) => c) div( cls := "min-h-full", - navigation, - pageHeader, + NavigationBar.render($m.map(_.navigation)), + PageHeader.render, main( mods, child <-- $maybeContent.map(_.getOrElse(Loading)) ) ) -} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala index 0f89d66..6d2f77b 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala @@ -8,4 +8,4 @@ class DashboardPageConnector(using router: Router[Page]): def render: HtmlElement = - AppPage().render(Val(Some(DashboardPage.render))) + AppPage.render(Val(Some(DashboardPage.render))) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala index 0140851..f970506 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala @@ -12,17 +12,30 @@ import cz.e_bs.cmi.mdr.pdb.ParameterCriteria // enum is not working with Waypoints' SplitRender collectStatic -sealed abstract class Page(val title: String, val parent: Option[Page]) +sealed abstract class Page( + val id: String, + val title: String, + val parent: Option[Page] +) { + val path: Vector[Page] = + parent match + case None => Vector(this) + case Some(p) => p.path :+ this +} object Page: - case object Directory extends Page("Adresář", None) + case object Directory extends Page("directory", "Adresář", None) - case object Dashboard extends Page("Přehled", Some(Directory)) + case object Dashboard extends Page("dashboard", "Přehled", Some(Directory)) // TODO: refactor to some "NamedParameter" concept, where the tuples value + title are better managed case class Detail(osobniCislo: OsobniCislo, jmenoOsoby: Option[String] = None) - extends Page(jmenoOsoby.getOrElse("Detail osoby"), Some(Directory)) + extends Page( + "user", + jmenoOsoby.getOrElse("Detail osoby"), + Some(Directory) + ) object Detail { def apply(o: UserInfo): Detail = Detail(o.personalNumber, Some(o.name)) @@ -34,6 +47,7 @@ jmenoOsoby: Option[String] = None, nazevParametru: Option[String] = None ) extends Page( + "parameter", nazevParametru.getOrElse("Detail parametru"), Some(Detail(osobniCislo, jmenoOsoby)) ) @@ -51,6 +65,7 @@ nazevParametru: Option[String] = None, nazevKriteria: Option[String] = None ) extends Page( + "criteria", nazevKriteria.getOrElse("Detail kriteria"), Some( DetailParametru(osobniCislo, idParametru, jmenoOsoby, nazevParametru) @@ -69,12 +84,12 @@ ) } - case class NotFound(url: String) extends Page("404", Some(Directory)) + case class NotFound(url: String) extends Page("404", "404", Some(Directory)) case class UnhandledError( errorName: Option[String], errorMessage: Option[String] - ) extends Page("Unexpected error", Some(Directory)) + ) extends Page("500", "Unexpected error", Some(Directory)) object Routes: given JsonDecoder[OsobniCislo] = JsonDecoder.string.map(OsobniCislo.apply) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala index ea5600a..e77d2a3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -6,30 +6,25 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import com.raquo.waypoint.Router -class AppPage(using router: Router[Page]) - extends PageLayout - with PageHeader - with Breadcrumbs - with NavigationBar[Page] - with Navigator[Page]: +object AppPage: // TODO: pages by logged in user val pages = List(Page.Directory, Page.Dashboard) - override val logo = Logo( + import NavigationBar.{Logo, MenuItem} + + val logo = Logo( "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", "Workflow" ) // TODO: menu items by user profile - override val userMenu = + val userMenu = List( MenuItem("Your Profile"), MenuItem("Settings"), MenuItem("Sign out") ) - override def pageTitle(page: Page): String = page.title - // TODO: load user profile val $userProfile = Var( UserProfile( @@ -50,4 +45,23 @@ ) ) - override val $userInfo = $userProfile.signal.map(_.userInfo) + val $userInfo = $userProfile.signal.map(_.userInfo) + + type ViewModel = Option[HtmlElement] + def render($m: Signal[ViewModel], mods: Modifier[HtmlElement]*)(using + Router[Page] + ): HtmlElement = + PageLayout.render( + $m.combineWith($userInfo).map((c, u) => + PageLayout.ViewModel( + NavigationBar.ViewModel( + u, + pages, + userMenu, + logo + ), + c + ) + ), + mods + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala index 038d2d8..89134a5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala @@ -6,70 +6,12 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import cz.e_bs.cmi.mdr.pdb.app.Page -trait Breadcrumbs(using router: Router[Page]): - self: Navigator[Page] => +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.components.list.IconText.ViewModel - def breadcrumbs: HtmlElement = - val $p = router.$currentPage - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) +object Breadcrumbs: - private def renderFull(page: Page): HtmlElement = - div( - cls := "hidden sm:block", - ol( - role := "list", - cls := "flex items-center space-x-4", - renderItems(page) - ) - ) - - private def renderShort(page: Page): HtmlElement = - div( - cls := "flex sm:hidden", - page.parent match { - case None => renderHome(page) - case Some(p) => - a( - navigateTo(p), - cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", - Icons.solid.`arrow-narrow-left`, - span(p.title) - ) - } - ) - - private def renderItems(page: Page): Seq[HtmlElement] = - page.parent match { - case None => Seq(li(div(renderHome(page)))) - case Some(p) => - renderItems(p) :+ li( - div( - cls := "flex items-center", - slash, - a( - navigateTo(p), - cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", - p.title - ) - ) - ) - } - - private def renderHome(page: Page) = - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "text-gray-400 hover:text-gray-500", - Icons.solid.home, - span(cls := "sr-only", "Home") - ) - - private def slash = { + private def slash = import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", @@ -81,4 +23,68 @@ d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) - } + + object Home: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + a( + Navigator.navigateTo($m), + cls := "text-gray-400 hover:text-gray-500", + Icons.solid.home, + span(cls := "sr-only", "Home") + ) + + object Segment: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + li( + div( + cls := "flex items-center", + slash, + a( + Navigator.navigateTo($m), + cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", + child.text <-- $m.map(_.title) + ) + ) + ) + + object FullBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "hidden sm:block", + ol( + role := "list", + cls := "flex items-center space-x-4", + Home($m.map(_.path.head)), + children <-- $m.map(_.path.tail) + .split(_.id)((_, _, $p) => Segment($p)) + ) + ) + + object ShortBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "flex sm:hidden", + child <-- $m.map( + _.parent match + case None => Home($m) + case Some(p) => + a( + Navigator.navigateTo($m), + cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", + Icons.solid.`arrow-narrow-left`, + span(p.title) + ) + ) + ) + + def apply()(using router: Router[Page]): HtmlElement = + nav( + cls := "flex", + aria.label := "Breadcrumb", + ShortBreadcrumbs(router.$currentPage), + FullBreadcrumbs(router.$currentPage) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala index ea6c8bc..8ba5360 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -5,236 +5,239 @@ import CustomAttrs.ariaCurrent import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.UserInfo +import cz.e_bs.cmi.mdr.pdb.app.Page -trait NavigationBar[Page](using router: Router[Page]): - self: Navigator[Page] => +object NavigationBar: case class Logo(img: String, name: String) case class MenuItem(title: String) - def $userInfo: Signal[UserInfo] - - def pages: List[Page] - def userMenu: List[MenuItem] - def logo: Logo - - // Extract title from the page object - def pageTitle(page: Page): String - - def navigation: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - private val mobileMenuOpen = Var(false) - - private val desktopOnly = cls("hidden md:block") - private val mobileOnly = cls("md:hidden") - - private inline def avatarImage(size: Int = 8) = - Avatar($userInfo.map(_.img)).avatarImage(size) - - private def notificationButton = button( - tpe := "button", - cls := List( - "bg-indigo-600", - "focus:outline-none", - "focus:ring-2", - "focus:ring-offset-2", - "focus:ring-offset-indigo-600", - "focus:ring-white", - "hover:text-white", - "p-1", - "rounded-full", - "text-indigo-200" - ), - span(cls := "sr-only", "View notifications"), - Icons.outline.bell + case class ViewModel( + userInfo: UserInfo, + pages: List[Page], + userMenu: List[MenuItem], + logo: Logo ) - private def userProfile: HtmlElement = - val menuOpen = Var(false) + def render($m: Signal[ViewModel])(using router: Router[Page]): HtmlElement = + val $userInfo = $m.map(_.userInfo) + val mobileMenuOpen = Var(false) - def menuItem(item: MenuItem, idx: Int): HtmlElement = - a( - href := "#", - cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", - role := "menuitem", - tabIndex := -1, - idAttr := s"user-menu-item-$idx", - item.title - ) + val desktopOnly = cls("hidden md:block") + val mobileOnly = cls("md:hidden") - div( - cls := "ml-3 relative", - div( - button( - tpe := "button", - cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - idAttr := "user-menu-button", - aria.expanded <-- menuOpen.signal, - aria.hasPopup := true, - span(cls := "sr-only", "Open user menu"), - child <-- avatarImage(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) + inline def avatarImage(size: Int = 8) = + Avatar($userInfo.map(_.img)).avatarImage(size) + + def notificationButton = button( + tpe := "button", + cls := List( + "bg-indigo-600", + "focus:outline-none", + "focus:ring-2", + "focus:ring-offset-2", + "focus:ring-offset-indigo-600", + "focus:ring-white", + "hover:text-white", + "p-1", + "rounded-full", + "text-indigo-200" ), - /* - * */ - div( - cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", - cls <-- menuOpen.signal.map { o => - if (o) "md:block" else "md:hidden" - }, - role := "menu", - aria.orientation := "vertical", - aria.labelledBy := "user-menu-button", - tabIndex := -1, - // : keyboard navigation - userMenu.zipWithIndex.map(menuItem) - ) + span(cls := "sr-only", "View notifications"), + Icons.outline.bell ) - private def mobileProfile = - def menuItem(item: MenuItem): HtmlElement = - a( - href := "#", - cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", - item.title - ) + def userProfile: HtmlElement = + val menuOpen = Var(false) - div( - cls := "pt-4 pb-3 border-t border-indigo-700", + def menuItem(item: MenuItem, idx: Int): HtmlElement = + a( + href := "#", + cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", + role := "menuitem", + tabIndex := -1, + idAttr := s"user-menu-item-$idx", + item.title + ) + div( - cls := "flex items-center px-5", + cls := "ml-3 relative", div( - cls := "flex-shrink-0", - child <-- avatarImage(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- $userInfo.map(_.name) - ), - child.maybe <-- $userInfo.map( - _.email.map(e => - div( - cls := "text-sm font-medium text-indigo-300", - e - ) - ) + button( + tpe := "button", + cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + idAttr := "user-menu-button", + aria.expanded <-- menuOpen.signal, + aria.hasPopup := true, + span(cls := "sr-only", "Open user menu"), + child <-- avatarImage(), + onClick.preventDefault.mapTo( + !menuOpen.now() + ) --> menuOpen.writer ) ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - userMenu.map(menuItem) + /* + * */ + div( + cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", + cls <-- menuOpen.signal.map { o => + if (o) "md:block" else "md:hidden" + }, + role := "menu", + aria.orientation := "vertical", + aria.labelledBy := "user-menu-button", + tabIndex := -1, + // : keyboard navigation + children <-- $m.map(_.userMenu.zipWithIndex.map(menuItem)) + ) ) + + def mobileProfile = + def menuItem(item: MenuItem): HtmlElement = + a( + href := "#", + cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", + item.title + ) + + div( + cls := "pt-4 pb-3 border-t border-indigo-700", + div( + cls := "flex items-center px-5", + div( + cls := "flex-shrink-0", + child <-- avatarImage(10) + ), + div( + cls := "ml-3", + div( + cls := "text-base font-medium text-white", + child.text <-- $userInfo.map(_.name) + ), + child.maybe <-- $userInfo.map( + _.email.map(e => + div( + cls := "text-sm font-medium text-indigo-300", + e + ) + ) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + children <-- $m.map(_.userMenu.map(menuItem)) + ) + ) + + def pageLink(page: Page, active: Signal[Boolean]): Anchor = + a( + Navigator.navigateTo(page), + cls <-- active.map { + case true => "bg-indigo-700" + case false => "hover:bg-indigo-500 hover:bg-opacity-75" + }, + cls := "text-white px-3 py-2 rounded-md text-sm font-medium", + ariaCurrent <-- active.map { + case true => "page" + case _ => "false" + }, + page.title + ) + + def logoImg: Image = + img( + cls := "h-8 w-8", + src <-- $m.map(_.logo.img), + alt <-- $m.map(_.logo.name) + ) + + def pageLinks(mods: Modifier[HtmlElement]*) = + $m.map( + _.pages.map(p => + pageLink(p, router.$currentPage.map(p == _)).amend(mods) + ) + ) + + def mobileMenuButton = button( + tpe := "button", + cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + aria.controls := "mobile-menu", + aria.expanded <-- mobileMenuOpen.signal, + span(cls := "sr-only", "Open main menu"), + Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "hidden" else "block" + }), + Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }), + onClick.preventDefault.mapTo( + !mobileMenuOpen.now() + ) --> mobileMenuOpen.writer ) - private def pageLink(page: Page, active: Signal[Boolean]): Anchor = - a( - navigateTo(page), - cls <-- active.map { - case true => "bg-indigo-700" - case false => "hover:bg-indigo-500 hover:bg-opacity-75" - }, - cls := "text-white px-3 py-2 rounded-md text-sm font-medium", - ariaCurrent <-- active.map { - case true => "page" - case _ => "false" - }, - pageTitle(page) - ) + def navBarLeft = + div( + cls := "flex items-center", + div(cls := "flex-shrink-0", logoImg), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + children <-- pageLinks() + ) + ) + ) - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = - pages.map(p => pageLink(p, router.$currentPage.map(p == _)).amend(mods)) - - private def mobileMenuButton = button( - tpe := "button", - cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - aria.controls := "mobile-menu", - aria.expanded <-- mobileMenuOpen.signal, - span(cls := "sr-only", "Open main menu"), - Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "hidden" else "block" - }), - Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }), - onClick.preventDefault.mapTo( - !mobileMenuOpen.now() - ) --> mobileMenuOpen.writer - ) - - private def navBarLeft = - div( - cls := "flex items-center", - div(cls := "flex-shrink-0", logoImg), + def navBarRight = div( desktopOnly, div( - cls := "ml-10 flex items-baseline space-x-4", - pageLinks() + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile ) ) - ) - private def navBarRight = - div( - desktopOnly, + def navBarMobile = div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile + cls := "-mr-2 flex", + mobileOnly, + mobileMenuButton ) - ) - private def navBarMobile = - div( - cls := "-mr-2 flex", - mobileOnly, - mobileMenuButton - ) - - private def navBar = - div( - cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + def navBar = div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile + cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + div( + cls := "flex items-center justify-between h-16", + navBarLeft, + navBarRight, + navBarMobile + ) ) - ) - private def mobileMenu = - div( - mobileOnly, - cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }, - idAttr := "mobile-menu", + def mobileMenu = div( - cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", - pageLinks(cls := "block") - ), - mobileProfile - ) + mobileOnly, + cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }, + idAttr := "mobile-menu", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + children <-- pageLinks(cls := "block") + ), + mobileProfile + ) + + nav(cls := "bg-indigo-600", navBar, mobileMenu) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala index 122dfd2..e1b65e5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -2,19 +2,17 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.Page -import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.waypoint.Router -trait PageHeader: - self: Breadcrumbs with Navigator[Page] => - - def pageHeader: HtmlElement = +object PageHeader: + def render(using Router[Page]): HtmlElement = header( cls := "bg-white shadow-sm", div( cls := "max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8", h1( cls := "text-lg leading-6 font-semibold text-gray-900", - breadcrumbs + Breadcrumbs() ) ) ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala index 006efa0..3ee6483 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -1,23 +1,25 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.Page -trait PageLayout { - def navigation: HtmlElement - def pageHeader: HtmlElement - +object PageLayout: + case class ViewModel( + navigation: NavigationBar.ViewModel, + content: Option[HtmlElement] + ) def render( - $m: Signal[Option[HtmlElement]], + $m: Signal[ViewModel], mods: Modifier[HtmlElement]* - ): HtmlElement = - val $maybeContent = $m.split(_ => ())((_, c, _) => c) + )(using Router[Page]): HtmlElement = + val $maybeContent = $m.map(_.content).split(_ => ())((_, c, _) => c) div( cls := "min-h-full", - navigation, - pageHeader, + NavigationBar.render($m.map(_.navigation)), + PageHeader.render, main( mods, child <-- $maybeContent.map(_.getOrElse(Loading)) ) ) -} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala index 0f89d66..6d2f77b 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala @@ -8,4 +8,4 @@ class DashboardPageConnector(using router: Router[Page]): def render: HtmlElement = - AppPage().render(Val(Some(DashboardPage.render))) + AppPage.render(Val(Some(DashboardPage.render))) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala index 31e6cc7..3af1a13 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala @@ -45,7 +45,7 @@ ) def render: HtmlElement = - AppPage().render( + AppPage.render( $merged.map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailKriteriaPage.render(s)), $pageChangeSignal --> state.actionBus diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala index 0140851..f970506 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala @@ -12,17 +12,30 @@ import cz.e_bs.cmi.mdr.pdb.ParameterCriteria // enum is not working with Waypoints' SplitRender collectStatic -sealed abstract class Page(val title: String, val parent: Option[Page]) +sealed abstract class Page( + val id: String, + val title: String, + val parent: Option[Page] +) { + val path: Vector[Page] = + parent match + case None => Vector(this) + case Some(p) => p.path :+ this +} object Page: - case object Directory extends Page("Adresář", None) + case object Directory extends Page("directory", "Adresář", None) - case object Dashboard extends Page("Přehled", Some(Directory)) + case object Dashboard extends Page("dashboard", "Přehled", Some(Directory)) // TODO: refactor to some "NamedParameter" concept, where the tuples value + title are better managed case class Detail(osobniCislo: OsobniCislo, jmenoOsoby: Option[String] = None) - extends Page(jmenoOsoby.getOrElse("Detail osoby"), Some(Directory)) + extends Page( + "user", + jmenoOsoby.getOrElse("Detail osoby"), + Some(Directory) + ) object Detail { def apply(o: UserInfo): Detail = Detail(o.personalNumber, Some(o.name)) @@ -34,6 +47,7 @@ jmenoOsoby: Option[String] = None, nazevParametru: Option[String] = None ) extends Page( + "parameter", nazevParametru.getOrElse("Detail parametru"), Some(Detail(osobniCislo, jmenoOsoby)) ) @@ -51,6 +65,7 @@ nazevParametru: Option[String] = None, nazevKriteria: Option[String] = None ) extends Page( + "criteria", nazevKriteria.getOrElse("Detail kriteria"), Some( DetailParametru(osobniCislo, idParametru, jmenoOsoby, nazevParametru) @@ -69,12 +84,12 @@ ) } - case class NotFound(url: String) extends Page("404", Some(Directory)) + case class NotFound(url: String) extends Page("404", "404", Some(Directory)) case class UnhandledError( errorName: Option[String], errorMessage: Option[String] - ) extends Page("Unexpected error", Some(Directory)) + ) extends Page("500", "Unexpected error", Some(Directory)) object Routes: given JsonDecoder[OsobniCislo] = JsonDecoder.string.map(OsobniCislo.apply) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala index ea5600a..e77d2a3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -6,30 +6,25 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import com.raquo.waypoint.Router -class AppPage(using router: Router[Page]) - extends PageLayout - with PageHeader - with Breadcrumbs - with NavigationBar[Page] - with Navigator[Page]: +object AppPage: // TODO: pages by logged in user val pages = List(Page.Directory, Page.Dashboard) - override val logo = Logo( + import NavigationBar.{Logo, MenuItem} + + val logo = Logo( "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", "Workflow" ) // TODO: menu items by user profile - override val userMenu = + val userMenu = List( MenuItem("Your Profile"), MenuItem("Settings"), MenuItem("Sign out") ) - override def pageTitle(page: Page): String = page.title - // TODO: load user profile val $userProfile = Var( UserProfile( @@ -50,4 +45,23 @@ ) ) - override val $userInfo = $userProfile.signal.map(_.userInfo) + val $userInfo = $userProfile.signal.map(_.userInfo) + + type ViewModel = Option[HtmlElement] + def render($m: Signal[ViewModel], mods: Modifier[HtmlElement]*)(using + Router[Page] + ): HtmlElement = + PageLayout.render( + $m.combineWith($userInfo).map((c, u) => + PageLayout.ViewModel( + NavigationBar.ViewModel( + u, + pages, + userMenu, + logo + ), + c + ) + ), + mods + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala index 038d2d8..89134a5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala @@ -6,70 +6,12 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import cz.e_bs.cmi.mdr.pdb.app.Page -trait Breadcrumbs(using router: Router[Page]): - self: Navigator[Page] => +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.components.list.IconText.ViewModel - def breadcrumbs: HtmlElement = - val $p = router.$currentPage - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) +object Breadcrumbs: - private def renderFull(page: Page): HtmlElement = - div( - cls := "hidden sm:block", - ol( - role := "list", - cls := "flex items-center space-x-4", - renderItems(page) - ) - ) - - private def renderShort(page: Page): HtmlElement = - div( - cls := "flex sm:hidden", - page.parent match { - case None => renderHome(page) - case Some(p) => - a( - navigateTo(p), - cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", - Icons.solid.`arrow-narrow-left`, - span(p.title) - ) - } - ) - - private def renderItems(page: Page): Seq[HtmlElement] = - page.parent match { - case None => Seq(li(div(renderHome(page)))) - case Some(p) => - renderItems(p) :+ li( - div( - cls := "flex items-center", - slash, - a( - navigateTo(p), - cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", - p.title - ) - ) - ) - } - - private def renderHome(page: Page) = - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "text-gray-400 hover:text-gray-500", - Icons.solid.home, - span(cls := "sr-only", "Home") - ) - - private def slash = { + private def slash = import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", @@ -81,4 +23,68 @@ d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) - } + + object Home: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + a( + Navigator.navigateTo($m), + cls := "text-gray-400 hover:text-gray-500", + Icons.solid.home, + span(cls := "sr-only", "Home") + ) + + object Segment: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + li( + div( + cls := "flex items-center", + slash, + a( + Navigator.navigateTo($m), + cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", + child.text <-- $m.map(_.title) + ) + ) + ) + + object FullBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "hidden sm:block", + ol( + role := "list", + cls := "flex items-center space-x-4", + Home($m.map(_.path.head)), + children <-- $m.map(_.path.tail) + .split(_.id)((_, _, $p) => Segment($p)) + ) + ) + + object ShortBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "flex sm:hidden", + child <-- $m.map( + _.parent match + case None => Home($m) + case Some(p) => + a( + Navigator.navigateTo($m), + cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", + Icons.solid.`arrow-narrow-left`, + span(p.title) + ) + ) + ) + + def apply()(using router: Router[Page]): HtmlElement = + nav( + cls := "flex", + aria.label := "Breadcrumb", + ShortBreadcrumbs(router.$currentPage), + FullBreadcrumbs(router.$currentPage) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala index ea6c8bc..8ba5360 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -5,236 +5,239 @@ import CustomAttrs.ariaCurrent import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.UserInfo +import cz.e_bs.cmi.mdr.pdb.app.Page -trait NavigationBar[Page](using router: Router[Page]): - self: Navigator[Page] => +object NavigationBar: case class Logo(img: String, name: String) case class MenuItem(title: String) - def $userInfo: Signal[UserInfo] - - def pages: List[Page] - def userMenu: List[MenuItem] - def logo: Logo - - // Extract title from the page object - def pageTitle(page: Page): String - - def navigation: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - private val mobileMenuOpen = Var(false) - - private val desktopOnly = cls("hidden md:block") - private val mobileOnly = cls("md:hidden") - - private inline def avatarImage(size: Int = 8) = - Avatar($userInfo.map(_.img)).avatarImage(size) - - private def notificationButton = button( - tpe := "button", - cls := List( - "bg-indigo-600", - "focus:outline-none", - "focus:ring-2", - "focus:ring-offset-2", - "focus:ring-offset-indigo-600", - "focus:ring-white", - "hover:text-white", - "p-1", - "rounded-full", - "text-indigo-200" - ), - span(cls := "sr-only", "View notifications"), - Icons.outline.bell + case class ViewModel( + userInfo: UserInfo, + pages: List[Page], + userMenu: List[MenuItem], + logo: Logo ) - private def userProfile: HtmlElement = - val menuOpen = Var(false) + def render($m: Signal[ViewModel])(using router: Router[Page]): HtmlElement = + val $userInfo = $m.map(_.userInfo) + val mobileMenuOpen = Var(false) - def menuItem(item: MenuItem, idx: Int): HtmlElement = - a( - href := "#", - cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", - role := "menuitem", - tabIndex := -1, - idAttr := s"user-menu-item-$idx", - item.title - ) + val desktopOnly = cls("hidden md:block") + val mobileOnly = cls("md:hidden") - div( - cls := "ml-3 relative", - div( - button( - tpe := "button", - cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - idAttr := "user-menu-button", - aria.expanded <-- menuOpen.signal, - aria.hasPopup := true, - span(cls := "sr-only", "Open user menu"), - child <-- avatarImage(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) + inline def avatarImage(size: Int = 8) = + Avatar($userInfo.map(_.img)).avatarImage(size) + + def notificationButton = button( + tpe := "button", + cls := List( + "bg-indigo-600", + "focus:outline-none", + "focus:ring-2", + "focus:ring-offset-2", + "focus:ring-offset-indigo-600", + "focus:ring-white", + "hover:text-white", + "p-1", + "rounded-full", + "text-indigo-200" ), - /* - * */ - div( - cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", - cls <-- menuOpen.signal.map { o => - if (o) "md:block" else "md:hidden" - }, - role := "menu", - aria.orientation := "vertical", - aria.labelledBy := "user-menu-button", - tabIndex := -1, - // : keyboard navigation - userMenu.zipWithIndex.map(menuItem) - ) + span(cls := "sr-only", "View notifications"), + Icons.outline.bell ) - private def mobileProfile = - def menuItem(item: MenuItem): HtmlElement = - a( - href := "#", - cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", - item.title - ) + def userProfile: HtmlElement = + val menuOpen = Var(false) - div( - cls := "pt-4 pb-3 border-t border-indigo-700", + def menuItem(item: MenuItem, idx: Int): HtmlElement = + a( + href := "#", + cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", + role := "menuitem", + tabIndex := -1, + idAttr := s"user-menu-item-$idx", + item.title + ) + div( - cls := "flex items-center px-5", + cls := "ml-3 relative", div( - cls := "flex-shrink-0", - child <-- avatarImage(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- $userInfo.map(_.name) - ), - child.maybe <-- $userInfo.map( - _.email.map(e => - div( - cls := "text-sm font-medium text-indigo-300", - e - ) - ) + button( + tpe := "button", + cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + idAttr := "user-menu-button", + aria.expanded <-- menuOpen.signal, + aria.hasPopup := true, + span(cls := "sr-only", "Open user menu"), + child <-- avatarImage(), + onClick.preventDefault.mapTo( + !menuOpen.now() + ) --> menuOpen.writer ) ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - userMenu.map(menuItem) + /* + * */ + div( + cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", + cls <-- menuOpen.signal.map { o => + if (o) "md:block" else "md:hidden" + }, + role := "menu", + aria.orientation := "vertical", + aria.labelledBy := "user-menu-button", + tabIndex := -1, + // : keyboard navigation + children <-- $m.map(_.userMenu.zipWithIndex.map(menuItem)) + ) ) + + def mobileProfile = + def menuItem(item: MenuItem): HtmlElement = + a( + href := "#", + cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", + item.title + ) + + div( + cls := "pt-4 pb-3 border-t border-indigo-700", + div( + cls := "flex items-center px-5", + div( + cls := "flex-shrink-0", + child <-- avatarImage(10) + ), + div( + cls := "ml-3", + div( + cls := "text-base font-medium text-white", + child.text <-- $userInfo.map(_.name) + ), + child.maybe <-- $userInfo.map( + _.email.map(e => + div( + cls := "text-sm font-medium text-indigo-300", + e + ) + ) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + children <-- $m.map(_.userMenu.map(menuItem)) + ) + ) + + def pageLink(page: Page, active: Signal[Boolean]): Anchor = + a( + Navigator.navigateTo(page), + cls <-- active.map { + case true => "bg-indigo-700" + case false => "hover:bg-indigo-500 hover:bg-opacity-75" + }, + cls := "text-white px-3 py-2 rounded-md text-sm font-medium", + ariaCurrent <-- active.map { + case true => "page" + case _ => "false" + }, + page.title + ) + + def logoImg: Image = + img( + cls := "h-8 w-8", + src <-- $m.map(_.logo.img), + alt <-- $m.map(_.logo.name) + ) + + def pageLinks(mods: Modifier[HtmlElement]*) = + $m.map( + _.pages.map(p => + pageLink(p, router.$currentPage.map(p == _)).amend(mods) + ) + ) + + def mobileMenuButton = button( + tpe := "button", + cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + aria.controls := "mobile-menu", + aria.expanded <-- mobileMenuOpen.signal, + span(cls := "sr-only", "Open main menu"), + Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "hidden" else "block" + }), + Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }), + onClick.preventDefault.mapTo( + !mobileMenuOpen.now() + ) --> mobileMenuOpen.writer ) - private def pageLink(page: Page, active: Signal[Boolean]): Anchor = - a( - navigateTo(page), - cls <-- active.map { - case true => "bg-indigo-700" - case false => "hover:bg-indigo-500 hover:bg-opacity-75" - }, - cls := "text-white px-3 py-2 rounded-md text-sm font-medium", - ariaCurrent <-- active.map { - case true => "page" - case _ => "false" - }, - pageTitle(page) - ) + def navBarLeft = + div( + cls := "flex items-center", + div(cls := "flex-shrink-0", logoImg), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + children <-- pageLinks() + ) + ) + ) - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = - pages.map(p => pageLink(p, router.$currentPage.map(p == _)).amend(mods)) - - private def mobileMenuButton = button( - tpe := "button", - cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - aria.controls := "mobile-menu", - aria.expanded <-- mobileMenuOpen.signal, - span(cls := "sr-only", "Open main menu"), - Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "hidden" else "block" - }), - Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }), - onClick.preventDefault.mapTo( - !mobileMenuOpen.now() - ) --> mobileMenuOpen.writer - ) - - private def navBarLeft = - div( - cls := "flex items-center", - div(cls := "flex-shrink-0", logoImg), + def navBarRight = div( desktopOnly, div( - cls := "ml-10 flex items-baseline space-x-4", - pageLinks() + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile ) ) - ) - private def navBarRight = - div( - desktopOnly, + def navBarMobile = div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile + cls := "-mr-2 flex", + mobileOnly, + mobileMenuButton ) - ) - private def navBarMobile = - div( - cls := "-mr-2 flex", - mobileOnly, - mobileMenuButton - ) - - private def navBar = - div( - cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + def navBar = div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile + cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + div( + cls := "flex items-center justify-between h-16", + navBarLeft, + navBarRight, + navBarMobile + ) ) - ) - private def mobileMenu = - div( - mobileOnly, - cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }, - idAttr := "mobile-menu", + def mobileMenu = div( - cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", - pageLinks(cls := "block") - ), - mobileProfile - ) + mobileOnly, + cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }, + idAttr := "mobile-menu", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + children <-- pageLinks(cls := "block") + ), + mobileProfile + ) + + nav(cls := "bg-indigo-600", navBar, mobileMenu) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala index 122dfd2..e1b65e5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -2,19 +2,17 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.Page -import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.waypoint.Router -trait PageHeader: - self: Breadcrumbs with Navigator[Page] => - - def pageHeader: HtmlElement = +object PageHeader: + def render(using Router[Page]): HtmlElement = header( cls := "bg-white shadow-sm", div( cls := "max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8", h1( cls := "text-lg leading-6 font-semibold text-gray-900", - breadcrumbs + Breadcrumbs() ) ) ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala index 006efa0..3ee6483 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -1,23 +1,25 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.Page -trait PageLayout { - def navigation: HtmlElement - def pageHeader: HtmlElement - +object PageLayout: + case class ViewModel( + navigation: NavigationBar.ViewModel, + content: Option[HtmlElement] + ) def render( - $m: Signal[Option[HtmlElement]], + $m: Signal[ViewModel], mods: Modifier[HtmlElement]* - ): HtmlElement = - val $maybeContent = $m.split(_ => ())((_, c, _) => c) + )(using Router[Page]): HtmlElement = + val $maybeContent = $m.map(_.content).split(_ => ())((_, c, _) => c) div( cls := "min-h-full", - navigation, - pageHeader, + NavigationBar.render($m.map(_.navigation)), + PageHeader.render, main( mods, child <-- $maybeContent.map(_.getOrElse(Loading)) ) ) -} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala index 0f89d66..6d2f77b 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala @@ -8,4 +8,4 @@ class DashboardPageConnector(using router: Router[Page]): def render: HtmlElement = - AppPage().render(Val(Some(DashboardPage.render))) + AppPage.render(Val(Some(DashboardPage.render))) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala index 31e6cc7..3af1a13 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala @@ -45,7 +45,7 @@ ) def render: HtmlElement = - AppPage().render( + AppPage.render( $merged.map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailKriteriaPage.render(s)), $pageChangeSignal --> state.actionBus diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala index 3520378..88cc209 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala @@ -35,7 +35,7 @@ val $params = state.parameters.startWithNone def render: HtmlElement = - AppPage().render( + AppPage.render( $data.combineWithFn($params)(_ zip _) .map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailPage.render(s)), diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala index 0140851..f970506 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala @@ -12,17 +12,30 @@ import cz.e_bs.cmi.mdr.pdb.ParameterCriteria // enum is not working with Waypoints' SplitRender collectStatic -sealed abstract class Page(val title: String, val parent: Option[Page]) +sealed abstract class Page( + val id: String, + val title: String, + val parent: Option[Page] +) { + val path: Vector[Page] = + parent match + case None => Vector(this) + case Some(p) => p.path :+ this +} object Page: - case object Directory extends Page("Adresář", None) + case object Directory extends Page("directory", "Adresář", None) - case object Dashboard extends Page("Přehled", Some(Directory)) + case object Dashboard extends Page("dashboard", "Přehled", Some(Directory)) // TODO: refactor to some "NamedParameter" concept, where the tuples value + title are better managed case class Detail(osobniCislo: OsobniCislo, jmenoOsoby: Option[String] = None) - extends Page(jmenoOsoby.getOrElse("Detail osoby"), Some(Directory)) + extends Page( + "user", + jmenoOsoby.getOrElse("Detail osoby"), + Some(Directory) + ) object Detail { def apply(o: UserInfo): Detail = Detail(o.personalNumber, Some(o.name)) @@ -34,6 +47,7 @@ jmenoOsoby: Option[String] = None, nazevParametru: Option[String] = None ) extends Page( + "parameter", nazevParametru.getOrElse("Detail parametru"), Some(Detail(osobniCislo, jmenoOsoby)) ) @@ -51,6 +65,7 @@ nazevParametru: Option[String] = None, nazevKriteria: Option[String] = None ) extends Page( + "criteria", nazevKriteria.getOrElse("Detail kriteria"), Some( DetailParametru(osobniCislo, idParametru, jmenoOsoby, nazevParametru) @@ -69,12 +84,12 @@ ) } - case class NotFound(url: String) extends Page("404", Some(Directory)) + case class NotFound(url: String) extends Page("404", "404", Some(Directory)) case class UnhandledError( errorName: Option[String], errorMessage: Option[String] - ) extends Page("Unexpected error", Some(Directory)) + ) extends Page("500", "Unexpected error", Some(Directory)) object Routes: given JsonDecoder[OsobniCislo] = JsonDecoder.string.map(OsobniCislo.apply) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala index ea5600a..e77d2a3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -6,30 +6,25 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import com.raquo.waypoint.Router -class AppPage(using router: Router[Page]) - extends PageLayout - with PageHeader - with Breadcrumbs - with NavigationBar[Page] - with Navigator[Page]: +object AppPage: // TODO: pages by logged in user val pages = List(Page.Directory, Page.Dashboard) - override val logo = Logo( + import NavigationBar.{Logo, MenuItem} + + val logo = Logo( "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", "Workflow" ) // TODO: menu items by user profile - override val userMenu = + val userMenu = List( MenuItem("Your Profile"), MenuItem("Settings"), MenuItem("Sign out") ) - override def pageTitle(page: Page): String = page.title - // TODO: load user profile val $userProfile = Var( UserProfile( @@ -50,4 +45,23 @@ ) ) - override val $userInfo = $userProfile.signal.map(_.userInfo) + val $userInfo = $userProfile.signal.map(_.userInfo) + + type ViewModel = Option[HtmlElement] + def render($m: Signal[ViewModel], mods: Modifier[HtmlElement]*)(using + Router[Page] + ): HtmlElement = + PageLayout.render( + $m.combineWith($userInfo).map((c, u) => + PageLayout.ViewModel( + NavigationBar.ViewModel( + u, + pages, + userMenu, + logo + ), + c + ) + ), + mods + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala index 038d2d8..89134a5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala @@ -6,70 +6,12 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import cz.e_bs.cmi.mdr.pdb.app.Page -trait Breadcrumbs(using router: Router[Page]): - self: Navigator[Page] => +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.components.list.IconText.ViewModel - def breadcrumbs: HtmlElement = - val $p = router.$currentPage - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) +object Breadcrumbs: - private def renderFull(page: Page): HtmlElement = - div( - cls := "hidden sm:block", - ol( - role := "list", - cls := "flex items-center space-x-4", - renderItems(page) - ) - ) - - private def renderShort(page: Page): HtmlElement = - div( - cls := "flex sm:hidden", - page.parent match { - case None => renderHome(page) - case Some(p) => - a( - navigateTo(p), - cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", - Icons.solid.`arrow-narrow-left`, - span(p.title) - ) - } - ) - - private def renderItems(page: Page): Seq[HtmlElement] = - page.parent match { - case None => Seq(li(div(renderHome(page)))) - case Some(p) => - renderItems(p) :+ li( - div( - cls := "flex items-center", - slash, - a( - navigateTo(p), - cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", - p.title - ) - ) - ) - } - - private def renderHome(page: Page) = - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "text-gray-400 hover:text-gray-500", - Icons.solid.home, - span(cls := "sr-only", "Home") - ) - - private def slash = { + private def slash = import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", @@ -81,4 +23,68 @@ d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) - } + + object Home: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + a( + Navigator.navigateTo($m), + cls := "text-gray-400 hover:text-gray-500", + Icons.solid.home, + span(cls := "sr-only", "Home") + ) + + object Segment: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + li( + div( + cls := "flex items-center", + slash, + a( + Navigator.navigateTo($m), + cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", + child.text <-- $m.map(_.title) + ) + ) + ) + + object FullBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "hidden sm:block", + ol( + role := "list", + cls := "flex items-center space-x-4", + Home($m.map(_.path.head)), + children <-- $m.map(_.path.tail) + .split(_.id)((_, _, $p) => Segment($p)) + ) + ) + + object ShortBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "flex sm:hidden", + child <-- $m.map( + _.parent match + case None => Home($m) + case Some(p) => + a( + Navigator.navigateTo($m), + cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", + Icons.solid.`arrow-narrow-left`, + span(p.title) + ) + ) + ) + + def apply()(using router: Router[Page]): HtmlElement = + nav( + cls := "flex", + aria.label := "Breadcrumb", + ShortBreadcrumbs(router.$currentPage), + FullBreadcrumbs(router.$currentPage) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala index ea6c8bc..8ba5360 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -5,236 +5,239 @@ import CustomAttrs.ariaCurrent import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.UserInfo +import cz.e_bs.cmi.mdr.pdb.app.Page -trait NavigationBar[Page](using router: Router[Page]): - self: Navigator[Page] => +object NavigationBar: case class Logo(img: String, name: String) case class MenuItem(title: String) - def $userInfo: Signal[UserInfo] - - def pages: List[Page] - def userMenu: List[MenuItem] - def logo: Logo - - // Extract title from the page object - def pageTitle(page: Page): String - - def navigation: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - private val mobileMenuOpen = Var(false) - - private val desktopOnly = cls("hidden md:block") - private val mobileOnly = cls("md:hidden") - - private inline def avatarImage(size: Int = 8) = - Avatar($userInfo.map(_.img)).avatarImage(size) - - private def notificationButton = button( - tpe := "button", - cls := List( - "bg-indigo-600", - "focus:outline-none", - "focus:ring-2", - "focus:ring-offset-2", - "focus:ring-offset-indigo-600", - "focus:ring-white", - "hover:text-white", - "p-1", - "rounded-full", - "text-indigo-200" - ), - span(cls := "sr-only", "View notifications"), - Icons.outline.bell + case class ViewModel( + userInfo: UserInfo, + pages: List[Page], + userMenu: List[MenuItem], + logo: Logo ) - private def userProfile: HtmlElement = - val menuOpen = Var(false) + def render($m: Signal[ViewModel])(using router: Router[Page]): HtmlElement = + val $userInfo = $m.map(_.userInfo) + val mobileMenuOpen = Var(false) - def menuItem(item: MenuItem, idx: Int): HtmlElement = - a( - href := "#", - cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", - role := "menuitem", - tabIndex := -1, - idAttr := s"user-menu-item-$idx", - item.title - ) + val desktopOnly = cls("hidden md:block") + val mobileOnly = cls("md:hidden") - div( - cls := "ml-3 relative", - div( - button( - tpe := "button", - cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - idAttr := "user-menu-button", - aria.expanded <-- menuOpen.signal, - aria.hasPopup := true, - span(cls := "sr-only", "Open user menu"), - child <-- avatarImage(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) + inline def avatarImage(size: Int = 8) = + Avatar($userInfo.map(_.img)).avatarImage(size) + + def notificationButton = button( + tpe := "button", + cls := List( + "bg-indigo-600", + "focus:outline-none", + "focus:ring-2", + "focus:ring-offset-2", + "focus:ring-offset-indigo-600", + "focus:ring-white", + "hover:text-white", + "p-1", + "rounded-full", + "text-indigo-200" ), - /* - * */ - div( - cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", - cls <-- menuOpen.signal.map { o => - if (o) "md:block" else "md:hidden" - }, - role := "menu", - aria.orientation := "vertical", - aria.labelledBy := "user-menu-button", - tabIndex := -1, - // : keyboard navigation - userMenu.zipWithIndex.map(menuItem) - ) + span(cls := "sr-only", "View notifications"), + Icons.outline.bell ) - private def mobileProfile = - def menuItem(item: MenuItem): HtmlElement = - a( - href := "#", - cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", - item.title - ) + def userProfile: HtmlElement = + val menuOpen = Var(false) - div( - cls := "pt-4 pb-3 border-t border-indigo-700", + def menuItem(item: MenuItem, idx: Int): HtmlElement = + a( + href := "#", + cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", + role := "menuitem", + tabIndex := -1, + idAttr := s"user-menu-item-$idx", + item.title + ) + div( - cls := "flex items-center px-5", + cls := "ml-3 relative", div( - cls := "flex-shrink-0", - child <-- avatarImage(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- $userInfo.map(_.name) - ), - child.maybe <-- $userInfo.map( - _.email.map(e => - div( - cls := "text-sm font-medium text-indigo-300", - e - ) - ) + button( + tpe := "button", + cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + idAttr := "user-menu-button", + aria.expanded <-- menuOpen.signal, + aria.hasPopup := true, + span(cls := "sr-only", "Open user menu"), + child <-- avatarImage(), + onClick.preventDefault.mapTo( + !menuOpen.now() + ) --> menuOpen.writer ) ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - userMenu.map(menuItem) + /* + * */ + div( + cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", + cls <-- menuOpen.signal.map { o => + if (o) "md:block" else "md:hidden" + }, + role := "menu", + aria.orientation := "vertical", + aria.labelledBy := "user-menu-button", + tabIndex := -1, + // : keyboard navigation + children <-- $m.map(_.userMenu.zipWithIndex.map(menuItem)) + ) ) + + def mobileProfile = + def menuItem(item: MenuItem): HtmlElement = + a( + href := "#", + cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", + item.title + ) + + div( + cls := "pt-4 pb-3 border-t border-indigo-700", + div( + cls := "flex items-center px-5", + div( + cls := "flex-shrink-0", + child <-- avatarImage(10) + ), + div( + cls := "ml-3", + div( + cls := "text-base font-medium text-white", + child.text <-- $userInfo.map(_.name) + ), + child.maybe <-- $userInfo.map( + _.email.map(e => + div( + cls := "text-sm font-medium text-indigo-300", + e + ) + ) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + children <-- $m.map(_.userMenu.map(menuItem)) + ) + ) + + def pageLink(page: Page, active: Signal[Boolean]): Anchor = + a( + Navigator.navigateTo(page), + cls <-- active.map { + case true => "bg-indigo-700" + case false => "hover:bg-indigo-500 hover:bg-opacity-75" + }, + cls := "text-white px-3 py-2 rounded-md text-sm font-medium", + ariaCurrent <-- active.map { + case true => "page" + case _ => "false" + }, + page.title + ) + + def logoImg: Image = + img( + cls := "h-8 w-8", + src <-- $m.map(_.logo.img), + alt <-- $m.map(_.logo.name) + ) + + def pageLinks(mods: Modifier[HtmlElement]*) = + $m.map( + _.pages.map(p => + pageLink(p, router.$currentPage.map(p == _)).amend(mods) + ) + ) + + def mobileMenuButton = button( + tpe := "button", + cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + aria.controls := "mobile-menu", + aria.expanded <-- mobileMenuOpen.signal, + span(cls := "sr-only", "Open main menu"), + Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "hidden" else "block" + }), + Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }), + onClick.preventDefault.mapTo( + !mobileMenuOpen.now() + ) --> mobileMenuOpen.writer ) - private def pageLink(page: Page, active: Signal[Boolean]): Anchor = - a( - navigateTo(page), - cls <-- active.map { - case true => "bg-indigo-700" - case false => "hover:bg-indigo-500 hover:bg-opacity-75" - }, - cls := "text-white px-3 py-2 rounded-md text-sm font-medium", - ariaCurrent <-- active.map { - case true => "page" - case _ => "false" - }, - pageTitle(page) - ) + def navBarLeft = + div( + cls := "flex items-center", + div(cls := "flex-shrink-0", logoImg), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + children <-- pageLinks() + ) + ) + ) - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = - pages.map(p => pageLink(p, router.$currentPage.map(p == _)).amend(mods)) - - private def mobileMenuButton = button( - tpe := "button", - cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - aria.controls := "mobile-menu", - aria.expanded <-- mobileMenuOpen.signal, - span(cls := "sr-only", "Open main menu"), - Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "hidden" else "block" - }), - Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }), - onClick.preventDefault.mapTo( - !mobileMenuOpen.now() - ) --> mobileMenuOpen.writer - ) - - private def navBarLeft = - div( - cls := "flex items-center", - div(cls := "flex-shrink-0", logoImg), + def navBarRight = div( desktopOnly, div( - cls := "ml-10 flex items-baseline space-x-4", - pageLinks() + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile ) ) - ) - private def navBarRight = - div( - desktopOnly, + def navBarMobile = div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile + cls := "-mr-2 flex", + mobileOnly, + mobileMenuButton ) - ) - private def navBarMobile = - div( - cls := "-mr-2 flex", - mobileOnly, - mobileMenuButton - ) - - private def navBar = - div( - cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + def navBar = div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile + cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + div( + cls := "flex items-center justify-between h-16", + navBarLeft, + navBarRight, + navBarMobile + ) ) - ) - private def mobileMenu = - div( - mobileOnly, - cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }, - idAttr := "mobile-menu", + def mobileMenu = div( - cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", - pageLinks(cls := "block") - ), - mobileProfile - ) + mobileOnly, + cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }, + idAttr := "mobile-menu", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + children <-- pageLinks(cls := "block") + ), + mobileProfile + ) + + nav(cls := "bg-indigo-600", navBar, mobileMenu) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala index 122dfd2..e1b65e5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -2,19 +2,17 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.Page -import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.waypoint.Router -trait PageHeader: - self: Breadcrumbs with Navigator[Page] => - - def pageHeader: HtmlElement = +object PageHeader: + def render(using Router[Page]): HtmlElement = header( cls := "bg-white shadow-sm", div( cls := "max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8", h1( cls := "text-lg leading-6 font-semibold text-gray-900", - breadcrumbs + Breadcrumbs() ) ) ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala index 006efa0..3ee6483 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -1,23 +1,25 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.Page -trait PageLayout { - def navigation: HtmlElement - def pageHeader: HtmlElement - +object PageLayout: + case class ViewModel( + navigation: NavigationBar.ViewModel, + content: Option[HtmlElement] + ) def render( - $m: Signal[Option[HtmlElement]], + $m: Signal[ViewModel], mods: Modifier[HtmlElement]* - ): HtmlElement = - val $maybeContent = $m.split(_ => ())((_, c, _) => c) + )(using Router[Page]): HtmlElement = + val $maybeContent = $m.map(_.content).split(_ => ())((_, c, _) => c) div( cls := "min-h-full", - navigation, - pageHeader, + NavigationBar.render($m.map(_.navigation)), + PageHeader.render, main( mods, child <-- $maybeContent.map(_.getOrElse(Loading)) ) ) -} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala index 0f89d66..6d2f77b 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala @@ -8,4 +8,4 @@ class DashboardPageConnector(using router: Router[Page]): def render: HtmlElement = - AppPage().render(Val(Some(DashboardPage.render))) + AppPage.render(Val(Some(DashboardPage.render))) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala index 31e6cc7..3af1a13 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala @@ -45,7 +45,7 @@ ) def render: HtmlElement = - AppPage().render( + AppPage.render( $merged.map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailKriteriaPage.render(s)), $pageChangeSignal --> state.actionBus diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala index 3520378..88cc209 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala @@ -35,7 +35,7 @@ val $params = state.parameters.startWithNone def render: HtmlElement = - AppPage().render( + AppPage.render( $data.combineWithFn($params)(_ zip _) .map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailPage.render(s)), diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala index a238c5b..d0836ee 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala @@ -41,7 +41,7 @@ ) def render: HtmlElement = - AppPage().render( + AppPage.render( $merged.map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailParametruPage.render(s)), $pageChangeSignal --> state.actionBus diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala index 0140851..f970506 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala @@ -12,17 +12,30 @@ import cz.e_bs.cmi.mdr.pdb.ParameterCriteria // enum is not working with Waypoints' SplitRender collectStatic -sealed abstract class Page(val title: String, val parent: Option[Page]) +sealed abstract class Page( + val id: String, + val title: String, + val parent: Option[Page] +) { + val path: Vector[Page] = + parent match + case None => Vector(this) + case Some(p) => p.path :+ this +} object Page: - case object Directory extends Page("Adresář", None) + case object Directory extends Page("directory", "Adresář", None) - case object Dashboard extends Page("Přehled", Some(Directory)) + case object Dashboard extends Page("dashboard", "Přehled", Some(Directory)) // TODO: refactor to some "NamedParameter" concept, where the tuples value + title are better managed case class Detail(osobniCislo: OsobniCislo, jmenoOsoby: Option[String] = None) - extends Page(jmenoOsoby.getOrElse("Detail osoby"), Some(Directory)) + extends Page( + "user", + jmenoOsoby.getOrElse("Detail osoby"), + Some(Directory) + ) object Detail { def apply(o: UserInfo): Detail = Detail(o.personalNumber, Some(o.name)) @@ -34,6 +47,7 @@ jmenoOsoby: Option[String] = None, nazevParametru: Option[String] = None ) extends Page( + "parameter", nazevParametru.getOrElse("Detail parametru"), Some(Detail(osobniCislo, jmenoOsoby)) ) @@ -51,6 +65,7 @@ nazevParametru: Option[String] = None, nazevKriteria: Option[String] = None ) extends Page( + "criteria", nazevKriteria.getOrElse("Detail kriteria"), Some( DetailParametru(osobniCislo, idParametru, jmenoOsoby, nazevParametru) @@ -69,12 +84,12 @@ ) } - case class NotFound(url: String) extends Page("404", Some(Directory)) + case class NotFound(url: String) extends Page("404", "404", Some(Directory)) case class UnhandledError( errorName: Option[String], errorMessage: Option[String] - ) extends Page("Unexpected error", Some(Directory)) + ) extends Page("500", "Unexpected error", Some(Directory)) object Routes: given JsonDecoder[OsobniCislo] = JsonDecoder.string.map(OsobniCislo.apply) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala index ea5600a..e77d2a3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -6,30 +6,25 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import com.raquo.waypoint.Router -class AppPage(using router: Router[Page]) - extends PageLayout - with PageHeader - with Breadcrumbs - with NavigationBar[Page] - with Navigator[Page]: +object AppPage: // TODO: pages by logged in user val pages = List(Page.Directory, Page.Dashboard) - override val logo = Logo( + import NavigationBar.{Logo, MenuItem} + + val logo = Logo( "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", "Workflow" ) // TODO: menu items by user profile - override val userMenu = + val userMenu = List( MenuItem("Your Profile"), MenuItem("Settings"), MenuItem("Sign out") ) - override def pageTitle(page: Page): String = page.title - // TODO: load user profile val $userProfile = Var( UserProfile( @@ -50,4 +45,23 @@ ) ) - override val $userInfo = $userProfile.signal.map(_.userInfo) + val $userInfo = $userProfile.signal.map(_.userInfo) + + type ViewModel = Option[HtmlElement] + def render($m: Signal[ViewModel], mods: Modifier[HtmlElement]*)(using + Router[Page] + ): HtmlElement = + PageLayout.render( + $m.combineWith($userInfo).map((c, u) => + PageLayout.ViewModel( + NavigationBar.ViewModel( + u, + pages, + userMenu, + logo + ), + c + ) + ), + mods + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala index 038d2d8..89134a5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala @@ -6,70 +6,12 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import cz.e_bs.cmi.mdr.pdb.app.Page -trait Breadcrumbs(using router: Router[Page]): - self: Navigator[Page] => +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.components.list.IconText.ViewModel - def breadcrumbs: HtmlElement = - val $p = router.$currentPage - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) +object Breadcrumbs: - private def renderFull(page: Page): HtmlElement = - div( - cls := "hidden sm:block", - ol( - role := "list", - cls := "flex items-center space-x-4", - renderItems(page) - ) - ) - - private def renderShort(page: Page): HtmlElement = - div( - cls := "flex sm:hidden", - page.parent match { - case None => renderHome(page) - case Some(p) => - a( - navigateTo(p), - cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", - Icons.solid.`arrow-narrow-left`, - span(p.title) - ) - } - ) - - private def renderItems(page: Page): Seq[HtmlElement] = - page.parent match { - case None => Seq(li(div(renderHome(page)))) - case Some(p) => - renderItems(p) :+ li( - div( - cls := "flex items-center", - slash, - a( - navigateTo(p), - cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", - p.title - ) - ) - ) - } - - private def renderHome(page: Page) = - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "text-gray-400 hover:text-gray-500", - Icons.solid.home, - span(cls := "sr-only", "Home") - ) - - private def slash = { + private def slash = import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", @@ -81,4 +23,68 @@ d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) - } + + object Home: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + a( + Navigator.navigateTo($m), + cls := "text-gray-400 hover:text-gray-500", + Icons.solid.home, + span(cls := "sr-only", "Home") + ) + + object Segment: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + li( + div( + cls := "flex items-center", + slash, + a( + Navigator.navigateTo($m), + cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", + child.text <-- $m.map(_.title) + ) + ) + ) + + object FullBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "hidden sm:block", + ol( + role := "list", + cls := "flex items-center space-x-4", + Home($m.map(_.path.head)), + children <-- $m.map(_.path.tail) + .split(_.id)((_, _, $p) => Segment($p)) + ) + ) + + object ShortBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "flex sm:hidden", + child <-- $m.map( + _.parent match + case None => Home($m) + case Some(p) => + a( + Navigator.navigateTo($m), + cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", + Icons.solid.`arrow-narrow-left`, + span(p.title) + ) + ) + ) + + def apply()(using router: Router[Page]): HtmlElement = + nav( + cls := "flex", + aria.label := "Breadcrumb", + ShortBreadcrumbs(router.$currentPage), + FullBreadcrumbs(router.$currentPage) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala index ea6c8bc..8ba5360 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -5,236 +5,239 @@ import CustomAttrs.ariaCurrent import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.UserInfo +import cz.e_bs.cmi.mdr.pdb.app.Page -trait NavigationBar[Page](using router: Router[Page]): - self: Navigator[Page] => +object NavigationBar: case class Logo(img: String, name: String) case class MenuItem(title: String) - def $userInfo: Signal[UserInfo] - - def pages: List[Page] - def userMenu: List[MenuItem] - def logo: Logo - - // Extract title from the page object - def pageTitle(page: Page): String - - def navigation: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - private val mobileMenuOpen = Var(false) - - private val desktopOnly = cls("hidden md:block") - private val mobileOnly = cls("md:hidden") - - private inline def avatarImage(size: Int = 8) = - Avatar($userInfo.map(_.img)).avatarImage(size) - - private def notificationButton = button( - tpe := "button", - cls := List( - "bg-indigo-600", - "focus:outline-none", - "focus:ring-2", - "focus:ring-offset-2", - "focus:ring-offset-indigo-600", - "focus:ring-white", - "hover:text-white", - "p-1", - "rounded-full", - "text-indigo-200" - ), - span(cls := "sr-only", "View notifications"), - Icons.outline.bell + case class ViewModel( + userInfo: UserInfo, + pages: List[Page], + userMenu: List[MenuItem], + logo: Logo ) - private def userProfile: HtmlElement = - val menuOpen = Var(false) + def render($m: Signal[ViewModel])(using router: Router[Page]): HtmlElement = + val $userInfo = $m.map(_.userInfo) + val mobileMenuOpen = Var(false) - def menuItem(item: MenuItem, idx: Int): HtmlElement = - a( - href := "#", - cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", - role := "menuitem", - tabIndex := -1, - idAttr := s"user-menu-item-$idx", - item.title - ) + val desktopOnly = cls("hidden md:block") + val mobileOnly = cls("md:hidden") - div( - cls := "ml-3 relative", - div( - button( - tpe := "button", - cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - idAttr := "user-menu-button", - aria.expanded <-- menuOpen.signal, - aria.hasPopup := true, - span(cls := "sr-only", "Open user menu"), - child <-- avatarImage(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) + inline def avatarImage(size: Int = 8) = + Avatar($userInfo.map(_.img)).avatarImage(size) + + def notificationButton = button( + tpe := "button", + cls := List( + "bg-indigo-600", + "focus:outline-none", + "focus:ring-2", + "focus:ring-offset-2", + "focus:ring-offset-indigo-600", + "focus:ring-white", + "hover:text-white", + "p-1", + "rounded-full", + "text-indigo-200" ), - /* - * */ - div( - cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", - cls <-- menuOpen.signal.map { o => - if (o) "md:block" else "md:hidden" - }, - role := "menu", - aria.orientation := "vertical", - aria.labelledBy := "user-menu-button", - tabIndex := -1, - // : keyboard navigation - userMenu.zipWithIndex.map(menuItem) - ) + span(cls := "sr-only", "View notifications"), + Icons.outline.bell ) - private def mobileProfile = - def menuItem(item: MenuItem): HtmlElement = - a( - href := "#", - cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", - item.title - ) + def userProfile: HtmlElement = + val menuOpen = Var(false) - div( - cls := "pt-4 pb-3 border-t border-indigo-700", + def menuItem(item: MenuItem, idx: Int): HtmlElement = + a( + href := "#", + cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", + role := "menuitem", + tabIndex := -1, + idAttr := s"user-menu-item-$idx", + item.title + ) + div( - cls := "flex items-center px-5", + cls := "ml-3 relative", div( - cls := "flex-shrink-0", - child <-- avatarImage(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- $userInfo.map(_.name) - ), - child.maybe <-- $userInfo.map( - _.email.map(e => - div( - cls := "text-sm font-medium text-indigo-300", - e - ) - ) + button( + tpe := "button", + cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + idAttr := "user-menu-button", + aria.expanded <-- menuOpen.signal, + aria.hasPopup := true, + span(cls := "sr-only", "Open user menu"), + child <-- avatarImage(), + onClick.preventDefault.mapTo( + !menuOpen.now() + ) --> menuOpen.writer ) ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - userMenu.map(menuItem) + /* + * */ + div( + cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", + cls <-- menuOpen.signal.map { o => + if (o) "md:block" else "md:hidden" + }, + role := "menu", + aria.orientation := "vertical", + aria.labelledBy := "user-menu-button", + tabIndex := -1, + // : keyboard navigation + children <-- $m.map(_.userMenu.zipWithIndex.map(menuItem)) + ) ) + + def mobileProfile = + def menuItem(item: MenuItem): HtmlElement = + a( + href := "#", + cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", + item.title + ) + + div( + cls := "pt-4 pb-3 border-t border-indigo-700", + div( + cls := "flex items-center px-5", + div( + cls := "flex-shrink-0", + child <-- avatarImage(10) + ), + div( + cls := "ml-3", + div( + cls := "text-base font-medium text-white", + child.text <-- $userInfo.map(_.name) + ), + child.maybe <-- $userInfo.map( + _.email.map(e => + div( + cls := "text-sm font-medium text-indigo-300", + e + ) + ) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + children <-- $m.map(_.userMenu.map(menuItem)) + ) + ) + + def pageLink(page: Page, active: Signal[Boolean]): Anchor = + a( + Navigator.navigateTo(page), + cls <-- active.map { + case true => "bg-indigo-700" + case false => "hover:bg-indigo-500 hover:bg-opacity-75" + }, + cls := "text-white px-3 py-2 rounded-md text-sm font-medium", + ariaCurrent <-- active.map { + case true => "page" + case _ => "false" + }, + page.title + ) + + def logoImg: Image = + img( + cls := "h-8 w-8", + src <-- $m.map(_.logo.img), + alt <-- $m.map(_.logo.name) + ) + + def pageLinks(mods: Modifier[HtmlElement]*) = + $m.map( + _.pages.map(p => + pageLink(p, router.$currentPage.map(p == _)).amend(mods) + ) + ) + + def mobileMenuButton = button( + tpe := "button", + cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + aria.controls := "mobile-menu", + aria.expanded <-- mobileMenuOpen.signal, + span(cls := "sr-only", "Open main menu"), + Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "hidden" else "block" + }), + Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }), + onClick.preventDefault.mapTo( + !mobileMenuOpen.now() + ) --> mobileMenuOpen.writer ) - private def pageLink(page: Page, active: Signal[Boolean]): Anchor = - a( - navigateTo(page), - cls <-- active.map { - case true => "bg-indigo-700" - case false => "hover:bg-indigo-500 hover:bg-opacity-75" - }, - cls := "text-white px-3 py-2 rounded-md text-sm font-medium", - ariaCurrent <-- active.map { - case true => "page" - case _ => "false" - }, - pageTitle(page) - ) + def navBarLeft = + div( + cls := "flex items-center", + div(cls := "flex-shrink-0", logoImg), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + children <-- pageLinks() + ) + ) + ) - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = - pages.map(p => pageLink(p, router.$currentPage.map(p == _)).amend(mods)) - - private def mobileMenuButton = button( - tpe := "button", - cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - aria.controls := "mobile-menu", - aria.expanded <-- mobileMenuOpen.signal, - span(cls := "sr-only", "Open main menu"), - Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "hidden" else "block" - }), - Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }), - onClick.preventDefault.mapTo( - !mobileMenuOpen.now() - ) --> mobileMenuOpen.writer - ) - - private def navBarLeft = - div( - cls := "flex items-center", - div(cls := "flex-shrink-0", logoImg), + def navBarRight = div( desktopOnly, div( - cls := "ml-10 flex items-baseline space-x-4", - pageLinks() + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile ) ) - ) - private def navBarRight = - div( - desktopOnly, + def navBarMobile = div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile + cls := "-mr-2 flex", + mobileOnly, + mobileMenuButton ) - ) - private def navBarMobile = - div( - cls := "-mr-2 flex", - mobileOnly, - mobileMenuButton - ) - - private def navBar = - div( - cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + def navBar = div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile + cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + div( + cls := "flex items-center justify-between h-16", + navBarLeft, + navBarRight, + navBarMobile + ) ) - ) - private def mobileMenu = - div( - mobileOnly, - cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }, - idAttr := "mobile-menu", + def mobileMenu = div( - cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", - pageLinks(cls := "block") - ), - mobileProfile - ) + mobileOnly, + cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }, + idAttr := "mobile-menu", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + children <-- pageLinks(cls := "block") + ), + mobileProfile + ) + + nav(cls := "bg-indigo-600", navBar, mobileMenu) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala index 122dfd2..e1b65e5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -2,19 +2,17 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.Page -import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.waypoint.Router -trait PageHeader: - self: Breadcrumbs with Navigator[Page] => - - def pageHeader: HtmlElement = +object PageHeader: + def render(using Router[Page]): HtmlElement = header( cls := "bg-white shadow-sm", div( cls := "max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8", h1( cls := "text-lg leading-6 font-semibold text-gray-900", - breadcrumbs + Breadcrumbs() ) ) ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala index 006efa0..3ee6483 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -1,23 +1,25 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.Page -trait PageLayout { - def navigation: HtmlElement - def pageHeader: HtmlElement - +object PageLayout: + case class ViewModel( + navigation: NavigationBar.ViewModel, + content: Option[HtmlElement] + ) def render( - $m: Signal[Option[HtmlElement]], + $m: Signal[ViewModel], mods: Modifier[HtmlElement]* - ): HtmlElement = - val $maybeContent = $m.split(_ => ())((_, c, _) => c) + )(using Router[Page]): HtmlElement = + val $maybeContent = $m.map(_.content).split(_ => ())((_, c, _) => c) div( cls := "min-h-full", - navigation, - pageHeader, + NavigationBar.render($m.map(_.navigation)), + PageHeader.render, main( mods, child <-- $maybeContent.map(_.getOrElse(Loading)) ) ) -} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala index 0f89d66..6d2f77b 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala @@ -8,4 +8,4 @@ class DashboardPageConnector(using router: Router[Page]): def render: HtmlElement = - AppPage().render(Val(Some(DashboardPage.render))) + AppPage.render(Val(Some(DashboardPage.render))) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala index 31e6cc7..3af1a13 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala @@ -45,7 +45,7 @@ ) def render: HtmlElement = - AppPage().render( + AppPage.render( $merged.map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailKriteriaPage.render(s)), $pageChangeSignal --> state.actionBus diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala index 3520378..88cc209 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala @@ -35,7 +35,7 @@ val $params = state.parameters.startWithNone def render: HtmlElement = - AppPage().render( + AppPage.render( $data.combineWithFn($params)(_ zip _) .map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailPage.render(s)), diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala index a238c5b..d0836ee 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala @@ -41,7 +41,7 @@ ) def render: HtmlElement = - AppPage().render( + AppPage.render( $merged.map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailParametruPage.render(s)), $pageChangeSignal --> state.actionBus diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DirectoryPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DirectoryPageConnector.scala index 92d8172..72af2d4 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DirectoryPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DirectoryPageConnector.scala @@ -15,7 +15,7 @@ val $actionSignal = EventStream.fromValue(FetchDirectory) def render: HtmlElement = - AppPage().render( + AppPage.render( $data.split(_ => ())((_, _, s) => pages.directory.DirectoryPage.render( s.map( diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala index 0140851..f970506 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala @@ -12,17 +12,30 @@ import cz.e_bs.cmi.mdr.pdb.ParameterCriteria // enum is not working with Waypoints' SplitRender collectStatic -sealed abstract class Page(val title: String, val parent: Option[Page]) +sealed abstract class Page( + val id: String, + val title: String, + val parent: Option[Page] +) { + val path: Vector[Page] = + parent match + case None => Vector(this) + case Some(p) => p.path :+ this +} object Page: - case object Directory extends Page("Adresář", None) + case object Directory extends Page("directory", "Adresář", None) - case object Dashboard extends Page("Přehled", Some(Directory)) + case object Dashboard extends Page("dashboard", "Přehled", Some(Directory)) // TODO: refactor to some "NamedParameter" concept, where the tuples value + title are better managed case class Detail(osobniCislo: OsobniCislo, jmenoOsoby: Option[String] = None) - extends Page(jmenoOsoby.getOrElse("Detail osoby"), Some(Directory)) + extends Page( + "user", + jmenoOsoby.getOrElse("Detail osoby"), + Some(Directory) + ) object Detail { def apply(o: UserInfo): Detail = Detail(o.personalNumber, Some(o.name)) @@ -34,6 +47,7 @@ jmenoOsoby: Option[String] = None, nazevParametru: Option[String] = None ) extends Page( + "parameter", nazevParametru.getOrElse("Detail parametru"), Some(Detail(osobniCislo, jmenoOsoby)) ) @@ -51,6 +65,7 @@ nazevParametru: Option[String] = None, nazevKriteria: Option[String] = None ) extends Page( + "criteria", nazevKriteria.getOrElse("Detail kriteria"), Some( DetailParametru(osobniCislo, idParametru, jmenoOsoby, nazevParametru) @@ -69,12 +84,12 @@ ) } - case class NotFound(url: String) extends Page("404", Some(Directory)) + case class NotFound(url: String) extends Page("404", "404", Some(Directory)) case class UnhandledError( errorName: Option[String], errorMessage: Option[String] - ) extends Page("Unexpected error", Some(Directory)) + ) extends Page("500", "Unexpected error", Some(Directory)) object Routes: given JsonDecoder[OsobniCislo] = JsonDecoder.string.map(OsobniCislo.apply) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala index ea5600a..e77d2a3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -6,30 +6,25 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import com.raquo.waypoint.Router -class AppPage(using router: Router[Page]) - extends PageLayout - with PageHeader - with Breadcrumbs - with NavigationBar[Page] - with Navigator[Page]: +object AppPage: // TODO: pages by logged in user val pages = List(Page.Directory, Page.Dashboard) - override val logo = Logo( + import NavigationBar.{Logo, MenuItem} + + val logo = Logo( "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", "Workflow" ) // TODO: menu items by user profile - override val userMenu = + val userMenu = List( MenuItem("Your Profile"), MenuItem("Settings"), MenuItem("Sign out") ) - override def pageTitle(page: Page): String = page.title - // TODO: load user profile val $userProfile = Var( UserProfile( @@ -50,4 +45,23 @@ ) ) - override val $userInfo = $userProfile.signal.map(_.userInfo) + val $userInfo = $userProfile.signal.map(_.userInfo) + + type ViewModel = Option[HtmlElement] + def render($m: Signal[ViewModel], mods: Modifier[HtmlElement]*)(using + Router[Page] + ): HtmlElement = + PageLayout.render( + $m.combineWith($userInfo).map((c, u) => + PageLayout.ViewModel( + NavigationBar.ViewModel( + u, + pages, + userMenu, + logo + ), + c + ) + ), + mods + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala index 038d2d8..89134a5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala @@ -6,70 +6,12 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import cz.e_bs.cmi.mdr.pdb.app.Page -trait Breadcrumbs(using router: Router[Page]): - self: Navigator[Page] => +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.components.list.IconText.ViewModel - def breadcrumbs: HtmlElement = - val $p = router.$currentPage - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) +object Breadcrumbs: - private def renderFull(page: Page): HtmlElement = - div( - cls := "hidden sm:block", - ol( - role := "list", - cls := "flex items-center space-x-4", - renderItems(page) - ) - ) - - private def renderShort(page: Page): HtmlElement = - div( - cls := "flex sm:hidden", - page.parent match { - case None => renderHome(page) - case Some(p) => - a( - navigateTo(p), - cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", - Icons.solid.`arrow-narrow-left`, - span(p.title) - ) - } - ) - - private def renderItems(page: Page): Seq[HtmlElement] = - page.parent match { - case None => Seq(li(div(renderHome(page)))) - case Some(p) => - renderItems(p) :+ li( - div( - cls := "flex items-center", - slash, - a( - navigateTo(p), - cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", - p.title - ) - ) - ) - } - - private def renderHome(page: Page) = - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "text-gray-400 hover:text-gray-500", - Icons.solid.home, - span(cls := "sr-only", "Home") - ) - - private def slash = { + private def slash = import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", @@ -81,4 +23,68 @@ d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) - } + + object Home: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + a( + Navigator.navigateTo($m), + cls := "text-gray-400 hover:text-gray-500", + Icons.solid.home, + span(cls := "sr-only", "Home") + ) + + object Segment: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + li( + div( + cls := "flex items-center", + slash, + a( + Navigator.navigateTo($m), + cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", + child.text <-- $m.map(_.title) + ) + ) + ) + + object FullBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "hidden sm:block", + ol( + role := "list", + cls := "flex items-center space-x-4", + Home($m.map(_.path.head)), + children <-- $m.map(_.path.tail) + .split(_.id)((_, _, $p) => Segment($p)) + ) + ) + + object ShortBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "flex sm:hidden", + child <-- $m.map( + _.parent match + case None => Home($m) + case Some(p) => + a( + Navigator.navigateTo($m), + cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", + Icons.solid.`arrow-narrow-left`, + span(p.title) + ) + ) + ) + + def apply()(using router: Router[Page]): HtmlElement = + nav( + cls := "flex", + aria.label := "Breadcrumb", + ShortBreadcrumbs(router.$currentPage), + FullBreadcrumbs(router.$currentPage) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala index ea6c8bc..8ba5360 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -5,236 +5,239 @@ import CustomAttrs.ariaCurrent import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.UserInfo +import cz.e_bs.cmi.mdr.pdb.app.Page -trait NavigationBar[Page](using router: Router[Page]): - self: Navigator[Page] => +object NavigationBar: case class Logo(img: String, name: String) case class MenuItem(title: String) - def $userInfo: Signal[UserInfo] - - def pages: List[Page] - def userMenu: List[MenuItem] - def logo: Logo - - // Extract title from the page object - def pageTitle(page: Page): String - - def navigation: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - private val mobileMenuOpen = Var(false) - - private val desktopOnly = cls("hidden md:block") - private val mobileOnly = cls("md:hidden") - - private inline def avatarImage(size: Int = 8) = - Avatar($userInfo.map(_.img)).avatarImage(size) - - private def notificationButton = button( - tpe := "button", - cls := List( - "bg-indigo-600", - "focus:outline-none", - "focus:ring-2", - "focus:ring-offset-2", - "focus:ring-offset-indigo-600", - "focus:ring-white", - "hover:text-white", - "p-1", - "rounded-full", - "text-indigo-200" - ), - span(cls := "sr-only", "View notifications"), - Icons.outline.bell + case class ViewModel( + userInfo: UserInfo, + pages: List[Page], + userMenu: List[MenuItem], + logo: Logo ) - private def userProfile: HtmlElement = - val menuOpen = Var(false) + def render($m: Signal[ViewModel])(using router: Router[Page]): HtmlElement = + val $userInfo = $m.map(_.userInfo) + val mobileMenuOpen = Var(false) - def menuItem(item: MenuItem, idx: Int): HtmlElement = - a( - href := "#", - cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", - role := "menuitem", - tabIndex := -1, - idAttr := s"user-menu-item-$idx", - item.title - ) + val desktopOnly = cls("hidden md:block") + val mobileOnly = cls("md:hidden") - div( - cls := "ml-3 relative", - div( - button( - tpe := "button", - cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - idAttr := "user-menu-button", - aria.expanded <-- menuOpen.signal, - aria.hasPopup := true, - span(cls := "sr-only", "Open user menu"), - child <-- avatarImage(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) + inline def avatarImage(size: Int = 8) = + Avatar($userInfo.map(_.img)).avatarImage(size) + + def notificationButton = button( + tpe := "button", + cls := List( + "bg-indigo-600", + "focus:outline-none", + "focus:ring-2", + "focus:ring-offset-2", + "focus:ring-offset-indigo-600", + "focus:ring-white", + "hover:text-white", + "p-1", + "rounded-full", + "text-indigo-200" ), - /* - * */ - div( - cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", - cls <-- menuOpen.signal.map { o => - if (o) "md:block" else "md:hidden" - }, - role := "menu", - aria.orientation := "vertical", - aria.labelledBy := "user-menu-button", - tabIndex := -1, - // : keyboard navigation - userMenu.zipWithIndex.map(menuItem) - ) + span(cls := "sr-only", "View notifications"), + Icons.outline.bell ) - private def mobileProfile = - def menuItem(item: MenuItem): HtmlElement = - a( - href := "#", - cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", - item.title - ) + def userProfile: HtmlElement = + val menuOpen = Var(false) - div( - cls := "pt-4 pb-3 border-t border-indigo-700", + def menuItem(item: MenuItem, idx: Int): HtmlElement = + a( + href := "#", + cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", + role := "menuitem", + tabIndex := -1, + idAttr := s"user-menu-item-$idx", + item.title + ) + div( - cls := "flex items-center px-5", + cls := "ml-3 relative", div( - cls := "flex-shrink-0", - child <-- avatarImage(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- $userInfo.map(_.name) - ), - child.maybe <-- $userInfo.map( - _.email.map(e => - div( - cls := "text-sm font-medium text-indigo-300", - e - ) - ) + button( + tpe := "button", + cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + idAttr := "user-menu-button", + aria.expanded <-- menuOpen.signal, + aria.hasPopup := true, + span(cls := "sr-only", "Open user menu"), + child <-- avatarImage(), + onClick.preventDefault.mapTo( + !menuOpen.now() + ) --> menuOpen.writer ) ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - userMenu.map(menuItem) + /* + * */ + div( + cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", + cls <-- menuOpen.signal.map { o => + if (o) "md:block" else "md:hidden" + }, + role := "menu", + aria.orientation := "vertical", + aria.labelledBy := "user-menu-button", + tabIndex := -1, + // : keyboard navigation + children <-- $m.map(_.userMenu.zipWithIndex.map(menuItem)) + ) ) + + def mobileProfile = + def menuItem(item: MenuItem): HtmlElement = + a( + href := "#", + cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", + item.title + ) + + div( + cls := "pt-4 pb-3 border-t border-indigo-700", + div( + cls := "flex items-center px-5", + div( + cls := "flex-shrink-0", + child <-- avatarImage(10) + ), + div( + cls := "ml-3", + div( + cls := "text-base font-medium text-white", + child.text <-- $userInfo.map(_.name) + ), + child.maybe <-- $userInfo.map( + _.email.map(e => + div( + cls := "text-sm font-medium text-indigo-300", + e + ) + ) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + children <-- $m.map(_.userMenu.map(menuItem)) + ) + ) + + def pageLink(page: Page, active: Signal[Boolean]): Anchor = + a( + Navigator.navigateTo(page), + cls <-- active.map { + case true => "bg-indigo-700" + case false => "hover:bg-indigo-500 hover:bg-opacity-75" + }, + cls := "text-white px-3 py-2 rounded-md text-sm font-medium", + ariaCurrent <-- active.map { + case true => "page" + case _ => "false" + }, + page.title + ) + + def logoImg: Image = + img( + cls := "h-8 w-8", + src <-- $m.map(_.logo.img), + alt <-- $m.map(_.logo.name) + ) + + def pageLinks(mods: Modifier[HtmlElement]*) = + $m.map( + _.pages.map(p => + pageLink(p, router.$currentPage.map(p == _)).amend(mods) + ) + ) + + def mobileMenuButton = button( + tpe := "button", + cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + aria.controls := "mobile-menu", + aria.expanded <-- mobileMenuOpen.signal, + span(cls := "sr-only", "Open main menu"), + Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "hidden" else "block" + }), + Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }), + onClick.preventDefault.mapTo( + !mobileMenuOpen.now() + ) --> mobileMenuOpen.writer ) - private def pageLink(page: Page, active: Signal[Boolean]): Anchor = - a( - navigateTo(page), - cls <-- active.map { - case true => "bg-indigo-700" - case false => "hover:bg-indigo-500 hover:bg-opacity-75" - }, - cls := "text-white px-3 py-2 rounded-md text-sm font-medium", - ariaCurrent <-- active.map { - case true => "page" - case _ => "false" - }, - pageTitle(page) - ) + def navBarLeft = + div( + cls := "flex items-center", + div(cls := "flex-shrink-0", logoImg), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + children <-- pageLinks() + ) + ) + ) - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = - pages.map(p => pageLink(p, router.$currentPage.map(p == _)).amend(mods)) - - private def mobileMenuButton = button( - tpe := "button", - cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - aria.controls := "mobile-menu", - aria.expanded <-- mobileMenuOpen.signal, - span(cls := "sr-only", "Open main menu"), - Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "hidden" else "block" - }), - Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }), - onClick.preventDefault.mapTo( - !mobileMenuOpen.now() - ) --> mobileMenuOpen.writer - ) - - private def navBarLeft = - div( - cls := "flex items-center", - div(cls := "flex-shrink-0", logoImg), + def navBarRight = div( desktopOnly, div( - cls := "ml-10 flex items-baseline space-x-4", - pageLinks() + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile ) ) - ) - private def navBarRight = - div( - desktopOnly, + def navBarMobile = div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile + cls := "-mr-2 flex", + mobileOnly, + mobileMenuButton ) - ) - private def navBarMobile = - div( - cls := "-mr-2 flex", - mobileOnly, - mobileMenuButton - ) - - private def navBar = - div( - cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + def navBar = div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile + cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + div( + cls := "flex items-center justify-between h-16", + navBarLeft, + navBarRight, + navBarMobile + ) ) - ) - private def mobileMenu = - div( - mobileOnly, - cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }, - idAttr := "mobile-menu", + def mobileMenu = div( - cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", - pageLinks(cls := "block") - ), - mobileProfile - ) + mobileOnly, + cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }, + idAttr := "mobile-menu", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + children <-- pageLinks(cls := "block") + ), + mobileProfile + ) + + nav(cls := "bg-indigo-600", navBar, mobileMenu) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala index 122dfd2..e1b65e5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -2,19 +2,17 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.Page -import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.waypoint.Router -trait PageHeader: - self: Breadcrumbs with Navigator[Page] => - - def pageHeader: HtmlElement = +object PageHeader: + def render(using Router[Page]): HtmlElement = header( cls := "bg-white shadow-sm", div( cls := "max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8", h1( cls := "text-lg leading-6 font-semibold text-gray-900", - breadcrumbs + Breadcrumbs() ) ) ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala index 006efa0..3ee6483 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -1,23 +1,25 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.Page -trait PageLayout { - def navigation: HtmlElement - def pageHeader: HtmlElement - +object PageLayout: + case class ViewModel( + navigation: NavigationBar.ViewModel, + content: Option[HtmlElement] + ) def render( - $m: Signal[Option[HtmlElement]], + $m: Signal[ViewModel], mods: Modifier[HtmlElement]* - ): HtmlElement = - val $maybeContent = $m.split(_ => ())((_, c, _) => c) + )(using Router[Page]): HtmlElement = + val $maybeContent = $m.map(_.content).split(_ => ())((_, c, _) => c) div( cls := "min-h-full", - navigation, - pageHeader, + NavigationBar.render($m.map(_.navigation)), + PageHeader.render, main( mods, child <-- $maybeContent.map(_.getOrElse(Loading)) ) ) -} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala index 0f89d66..6d2f77b 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala @@ -8,4 +8,4 @@ class DashboardPageConnector(using router: Router[Page]): def render: HtmlElement = - AppPage().render(Val(Some(DashboardPage.render))) + AppPage.render(Val(Some(DashboardPage.render))) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala index 31e6cc7..3af1a13 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala @@ -45,7 +45,7 @@ ) def render: HtmlElement = - AppPage().render( + AppPage.render( $merged.map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailKriteriaPage.render(s)), $pageChangeSignal --> state.actionBus diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala index 3520378..88cc209 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala @@ -35,7 +35,7 @@ val $params = state.parameters.startWithNone def render: HtmlElement = - AppPage().render( + AppPage.render( $data.combineWithFn($params)(_ zip _) .map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailPage.render(s)), diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala index a238c5b..d0836ee 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala @@ -41,7 +41,7 @@ ) def render: HtmlElement = - AppPage().render( + AppPage.render( $merged.map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailParametruPage.render(s)), $pageChangeSignal --> state.actionBus diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DirectoryPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DirectoryPageConnector.scala index 92d8172..72af2d4 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DirectoryPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DirectoryPageConnector.scala @@ -15,7 +15,7 @@ val $actionSignal = EventStream.fromValue(FetchDirectory) def render: HtmlElement = - AppPage().render( + AppPage.render( $data.split(_ => ())((_, _, s) => pages.directory.DirectoryPage.render( s.map( diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/detail/components/DetailKriteria.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/detail/components/DetailKriteria.scala index 39aad7d..1e2c30d 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/detail/components/DetailKriteria.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/detail/components/DetailKriteria.scala @@ -1,11 +1,167 @@ package cz.e_bs.cmi.mdr.pdb.app.pages.detail.components import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.components.CustomAttrs object DetailKriteria: type ViewModel = SeznamKriterii.Kriterium def render($m: Signal[ViewModel]): HtmlElement = div( h3(cls := "text-l font-bold text-gray-900"), - child.text <-- $m.map(_.nazev) + child.text <-- $m.map(_.nazev), + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + """Applicant Information""" + ), + p( + cls := "mt-1 max-w-2xl text-sm text-gray-500", + """Personal details and application.""" + ) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:px-6", + dl( + cls := "grid grid-cols-1 gap-x-4 gap-y-8 sm:grid-cols-2", + div( + cls := "sm:col-span-1", + dt( + cls := "text-sm font-medium text-gray-500", + """Full name""" + ), + dd( + cls := "mt-1 text-sm text-gray-900", + """Margot Foster""" + ) + ), + div( + cls := "sm:col-span-1", + dt( + cls := "text-sm font-medium text-gray-500", + """Application for""" + ), + dd( + cls := "mt-1 text-sm text-gray-900", + """Backend Developer""" + ) + ), + div( + cls := "sm:col-span-1", + dt( + cls := "text-sm font-medium text-gray-500", + """Email address""" + ), + dd( + cls := "mt-1 text-sm text-gray-900", + """margotfoster@example.com""" + ) + ), + div( + cls := "sm:col-span-1", + dt( + cls := "text-sm font-medium text-gray-500", + """Salary expectation""" + ), + dd( + cls := "mt-1 text-sm text-gray-900", + """$120,000""" + ) + ), + div( + cls := "sm:col-span-2", + dt( + cls := "text-sm font-medium text-gray-500", + """About""" + ), + dd( + cls := "mt-1 text-sm text-gray-900", + """Fugiat ipsum ipsum deserunt culpa aute sint do nostrud anim incididunt cillum culpa consequat. Excepteur qui ipsum aliquip consequat sint. Sit id mollit nulla mollit nostrud in ea officia proident. Irure nostrud pariatur mollit ad adipisicing reprehenderit deserunt qui eu.""" + ) + ), + div( + cls := "sm:col-span-2", + dt( + cls := "text-sm font-medium text-gray-500", + """Attachments""" + ), + dd( + cls := "mt-1 text-sm text-gray-900", + ul( + role := "list", + cls := "border border-gray-200 rounded-md divide-y divide-gray-200", + li( + cls := "pl-3 pr-4 py-3 flex items-center justify-between text-sm", + div( + cls := "w-0 flex-1 flex items-center", { + import svg.* + import CustomAttrs.svg.ariaHidden + svg( + cls := "flex-shrink-0 h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + ariaHidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + }, + span( + cls := "ml-2 flex-1 w-0 truncate", + """resume_back_end_developer.pdf""" + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := "#", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + """Download""" + ) + ) + ), + li( + cls := "pl-3 pr-4 py-3 flex items-center justify-between text-sm", + div( + cls := "w-0 flex-1 flex items-center", { + import svg.* + import CustomAttrs.svg.ariaHidden + svg( + cls := "flex-shrink-0 h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + ariaHidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + }, + span( + cls := "ml-2 flex-1 w-0 truncate", + """coverletter_back_end_developer.pdf""" + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := "#", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + """Download""" + ) + ) + ) + ) + ) + ) + ) + ) + ) ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala index 0140851..f970506 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Routes.scala @@ -12,17 +12,30 @@ import cz.e_bs.cmi.mdr.pdb.ParameterCriteria // enum is not working with Waypoints' SplitRender collectStatic -sealed abstract class Page(val title: String, val parent: Option[Page]) +sealed abstract class Page( + val id: String, + val title: String, + val parent: Option[Page] +) { + val path: Vector[Page] = + parent match + case None => Vector(this) + case Some(p) => p.path :+ this +} object Page: - case object Directory extends Page("Adresář", None) + case object Directory extends Page("directory", "Adresář", None) - case object Dashboard extends Page("Přehled", Some(Directory)) + case object Dashboard extends Page("dashboard", "Přehled", Some(Directory)) // TODO: refactor to some "NamedParameter" concept, where the tuples value + title are better managed case class Detail(osobniCislo: OsobniCislo, jmenoOsoby: Option[String] = None) - extends Page(jmenoOsoby.getOrElse("Detail osoby"), Some(Directory)) + extends Page( + "user", + jmenoOsoby.getOrElse("Detail osoby"), + Some(Directory) + ) object Detail { def apply(o: UserInfo): Detail = Detail(o.personalNumber, Some(o.name)) @@ -34,6 +47,7 @@ jmenoOsoby: Option[String] = None, nazevParametru: Option[String] = None ) extends Page( + "parameter", nazevParametru.getOrElse("Detail parametru"), Some(Detail(osobniCislo, jmenoOsoby)) ) @@ -51,6 +65,7 @@ nazevParametru: Option[String] = None, nazevKriteria: Option[String] = None ) extends Page( + "criteria", nazevKriteria.getOrElse("Detail kriteria"), Some( DetailParametru(osobniCislo, idParametru, jmenoOsoby, nazevParametru) @@ -69,12 +84,12 @@ ) } - case class NotFound(url: String) extends Page("404", Some(Directory)) + case class NotFound(url: String) extends Page("404", "404", Some(Directory)) case class UnhandledError( errorName: Option[String], errorMessage: Option[String] - ) extends Page("Unexpected error", Some(Directory)) + ) extends Page("500", "Unexpected error", Some(Directory)) object Routes: given JsonDecoder[OsobniCislo] = JsonDecoder.string.map(OsobniCislo.apply) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala index ea5600a..e77d2a3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -6,30 +6,25 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import com.raquo.waypoint.Router -class AppPage(using router: Router[Page]) - extends PageLayout - with PageHeader - with Breadcrumbs - with NavigationBar[Page] - with Navigator[Page]: +object AppPage: // TODO: pages by logged in user val pages = List(Page.Directory, Page.Dashboard) - override val logo = Logo( + import NavigationBar.{Logo, MenuItem} + + val logo = Logo( "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", "Workflow" ) // TODO: menu items by user profile - override val userMenu = + val userMenu = List( MenuItem("Your Profile"), MenuItem("Settings"), MenuItem("Sign out") ) - override def pageTitle(page: Page): String = page.title - // TODO: load user profile val $userProfile = Var( UserProfile( @@ -50,4 +45,23 @@ ) ) - override val $userInfo = $userProfile.signal.map(_.userInfo) + val $userInfo = $userProfile.signal.map(_.userInfo) + + type ViewModel = Option[HtmlElement] + def render($m: Signal[ViewModel], mods: Modifier[HtmlElement]*)(using + Router[Page] + ): HtmlElement = + PageLayout.render( + $m.combineWith($userInfo).map((c, u) => + PageLayout.ViewModel( + NavigationBar.ViewModel( + u, + pages, + userMenu, + logo + ), + c + ) + ), + mods + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala index 038d2d8..89134a5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Breadcrumbs.scala @@ -6,70 +6,12 @@ import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator import cz.e_bs.cmi.mdr.pdb.app.Page -trait Breadcrumbs(using router: Router[Page]): - self: Navigator[Page] => +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.components.list.IconText.ViewModel - def breadcrumbs: HtmlElement = - val $p = router.$currentPage - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) +object Breadcrumbs: - private def renderFull(page: Page): HtmlElement = - div( - cls := "hidden sm:block", - ol( - role := "list", - cls := "flex items-center space-x-4", - renderItems(page) - ) - ) - - private def renderShort(page: Page): HtmlElement = - div( - cls := "flex sm:hidden", - page.parent match { - case None => renderHome(page) - case Some(p) => - a( - navigateTo(p), - cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", - Icons.solid.`arrow-narrow-left`, - span(p.title) - ) - } - ) - - private def renderItems(page: Page): Seq[HtmlElement] = - page.parent match { - case None => Seq(li(div(renderHome(page)))) - case Some(p) => - renderItems(p) :+ li( - div( - cls := "flex items-center", - slash, - a( - navigateTo(p), - cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", - p.title - ) - ) - ) - } - - private def renderHome(page: Page) = - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "text-gray-400 hover:text-gray-500", - Icons.solid.home, - span(cls := "sr-only", "Home") - ) - - private def slash = { + private def slash = import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", @@ -81,4 +23,68 @@ d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) - } + + object Home: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + a( + Navigator.navigateTo($m), + cls := "text-gray-400 hover:text-gray-500", + Icons.solid.home, + span(cls := "sr-only", "Home") + ) + + object Segment: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + li( + div( + cls := "flex items-center", + slash, + a( + Navigator.navigateTo($m), + cls := "ml-4 text-sm font-medium text-gray-500 hover:text-gray-700", + child.text <-- $m.map(_.title) + ) + ) + ) + + object FullBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "hidden sm:block", + ol( + role := "list", + cls := "flex items-center space-x-4", + Home($m.map(_.path.head)), + children <-- $m.map(_.path.tail) + .split(_.id)((_, _, $p) => Segment($p)) + ) + ) + + object ShortBreadcrumbs: + type ViewModel = Page + def apply($m: Signal[ViewModel])(using Router[Page]): HtmlElement = + div( + cls := "flex sm:hidden", + child <-- $m.map( + _.parent match + case None => Home($m) + case Some(p) => + a( + Navigator.navigateTo($m), + cls := "group inline-flex space-x-3 text-sm font-medium text-gray-500 hover:text-gray-700", + Icons.solid.`arrow-narrow-left`, + span(p.title) + ) + ) + ) + + def apply()(using router: Router[Page]): HtmlElement = + nav( + cls := "flex", + aria.label := "Breadcrumb", + ShortBreadcrumbs(router.$currentPage), + FullBreadcrumbs(router.$currentPage) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala index ea6c8bc..8ba5360 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -5,236 +5,239 @@ import CustomAttrs.ariaCurrent import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.UserInfo +import cz.e_bs.cmi.mdr.pdb.app.Page -trait NavigationBar[Page](using router: Router[Page]): - self: Navigator[Page] => +object NavigationBar: case class Logo(img: String, name: String) case class MenuItem(title: String) - def $userInfo: Signal[UserInfo] - - def pages: List[Page] - def userMenu: List[MenuItem] - def logo: Logo - - // Extract title from the page object - def pageTitle(page: Page): String - - def navigation: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - private val mobileMenuOpen = Var(false) - - private val desktopOnly = cls("hidden md:block") - private val mobileOnly = cls("md:hidden") - - private inline def avatarImage(size: Int = 8) = - Avatar($userInfo.map(_.img)).avatarImage(size) - - private def notificationButton = button( - tpe := "button", - cls := List( - "bg-indigo-600", - "focus:outline-none", - "focus:ring-2", - "focus:ring-offset-2", - "focus:ring-offset-indigo-600", - "focus:ring-white", - "hover:text-white", - "p-1", - "rounded-full", - "text-indigo-200" - ), - span(cls := "sr-only", "View notifications"), - Icons.outline.bell + case class ViewModel( + userInfo: UserInfo, + pages: List[Page], + userMenu: List[MenuItem], + logo: Logo ) - private def userProfile: HtmlElement = - val menuOpen = Var(false) + def render($m: Signal[ViewModel])(using router: Router[Page]): HtmlElement = + val $userInfo = $m.map(_.userInfo) + val mobileMenuOpen = Var(false) - def menuItem(item: MenuItem, idx: Int): HtmlElement = - a( - href := "#", - cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", - role := "menuitem", - tabIndex := -1, - idAttr := s"user-menu-item-$idx", - item.title - ) + val desktopOnly = cls("hidden md:block") + val mobileOnly = cls("md:hidden") - div( - cls := "ml-3 relative", - div( - button( - tpe := "button", - cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - idAttr := "user-menu-button", - aria.expanded <-- menuOpen.signal, - aria.hasPopup := true, - span(cls := "sr-only", "Open user menu"), - child <-- avatarImage(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) + inline def avatarImage(size: Int = 8) = + Avatar($userInfo.map(_.img)).avatarImage(size) + + def notificationButton = button( + tpe := "button", + cls := List( + "bg-indigo-600", + "focus:outline-none", + "focus:ring-2", + "focus:ring-offset-2", + "focus:ring-offset-indigo-600", + "focus:ring-white", + "hover:text-white", + "p-1", + "rounded-full", + "text-indigo-200" ), - /* - * */ - div( - cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", - cls <-- menuOpen.signal.map { o => - if (o) "md:block" else "md:hidden" - }, - role := "menu", - aria.orientation := "vertical", - aria.labelledBy := "user-menu-button", - tabIndex := -1, - // : keyboard navigation - userMenu.zipWithIndex.map(menuItem) - ) + span(cls := "sr-only", "View notifications"), + Icons.outline.bell ) - private def mobileProfile = - def menuItem(item: MenuItem): HtmlElement = - a( - href := "#", - cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", - item.title - ) + def userProfile: HtmlElement = + val menuOpen = Var(false) - div( - cls := "pt-4 pb-3 border-t border-indigo-700", + def menuItem(item: MenuItem, idx: Int): HtmlElement = + a( + href := "#", + cls := "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100", + role := "menuitem", + tabIndex := -1, + idAttr := s"user-menu-item-$idx", + item.title + ) + div( - cls := "flex items-center px-5", + cls := "ml-3 relative", div( - cls := "flex-shrink-0", - child <-- avatarImage(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- $userInfo.map(_.name) - ), - child.maybe <-- $userInfo.map( - _.email.map(e => - div( - cls := "text-sm font-medium text-indigo-300", - e - ) - ) + button( + tpe := "button", + cls := "max-w-xs bg-indigo-600 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + idAttr := "user-menu-button", + aria.expanded <-- menuOpen.signal, + aria.hasPopup := true, + span(cls := "sr-only", "Open user menu"), + child <-- avatarImage(), + onClick.preventDefault.mapTo( + !menuOpen.now() + ) --> menuOpen.writer ) ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - userMenu.map(menuItem) + /* + * */ + div( + cls := "origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none", + cls <-- menuOpen.signal.map { o => + if (o) "md:block" else "md:hidden" + }, + role := "menu", + aria.orientation := "vertical", + aria.labelledBy := "user-menu-button", + tabIndex := -1, + // : keyboard navigation + children <-- $m.map(_.userMenu.zipWithIndex.map(menuItem)) + ) ) + + def mobileProfile = + def menuItem(item: MenuItem): HtmlElement = + a( + href := "#", + cls := "block px-3 py-2 rounded-md text-base font-medium text-white hover:bg-indigo-500 hover:bg-opacity-75", + item.title + ) + + div( + cls := "pt-4 pb-3 border-t border-indigo-700", + div( + cls := "flex items-center px-5", + div( + cls := "flex-shrink-0", + child <-- avatarImage(10) + ), + div( + cls := "ml-3", + div( + cls := "text-base font-medium text-white", + child.text <-- $userInfo.map(_.name) + ), + child.maybe <-- $userInfo.map( + _.email.map(e => + div( + cls := "text-sm font-medium text-indigo-300", + e + ) + ) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + children <-- $m.map(_.userMenu.map(menuItem)) + ) + ) + + def pageLink(page: Page, active: Signal[Boolean]): Anchor = + a( + Navigator.navigateTo(page), + cls <-- active.map { + case true => "bg-indigo-700" + case false => "hover:bg-indigo-500 hover:bg-opacity-75" + }, + cls := "text-white px-3 py-2 rounded-md text-sm font-medium", + ariaCurrent <-- active.map { + case true => "page" + case _ => "false" + }, + page.title + ) + + def logoImg: Image = + img( + cls := "h-8 w-8", + src <-- $m.map(_.logo.img), + alt <-- $m.map(_.logo.name) + ) + + def pageLinks(mods: Modifier[HtmlElement]*) = + $m.map( + _.pages.map(p => + pageLink(p, router.$currentPage.map(p == _)).amend(mods) + ) + ) + + def mobileMenuButton = button( + tpe := "button", + cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", + aria.controls := "mobile-menu", + aria.expanded <-- mobileMenuOpen.signal, + span(cls := "sr-only", "Open main menu"), + Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "hidden" else "block" + }), + Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }), + onClick.preventDefault.mapTo( + !mobileMenuOpen.now() + ) --> mobileMenuOpen.writer ) - private def pageLink(page: Page, active: Signal[Boolean]): Anchor = - a( - navigateTo(page), - cls <-- active.map { - case true => "bg-indigo-700" - case false => "hover:bg-indigo-500 hover:bg-opacity-75" - }, - cls := "text-white px-3 py-2 rounded-md text-sm font-medium", - ariaCurrent <-- active.map { - case true => "page" - case _ => "false" - }, - pageTitle(page) - ) + def navBarLeft = + div( + cls := "flex items-center", + div(cls := "flex-shrink-0", logoImg), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + children <-- pageLinks() + ) + ) + ) - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = - pages.map(p => pageLink(p, router.$currentPage.map(p == _)).amend(mods)) - - private def mobileMenuButton = button( - tpe := "button", - cls := "bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white", - aria.controls := "mobile-menu", - aria.expanded <-- mobileMenuOpen.signal, - span(cls := "sr-only", "Open main menu"), - Icons.outline.menu.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "hidden" else "block" - }), - Icons.outline.x.amend(svg.cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }), - onClick.preventDefault.mapTo( - !mobileMenuOpen.now() - ) --> mobileMenuOpen.writer - ) - - private def navBarLeft = - div( - cls := "flex items-center", - div(cls := "flex-shrink-0", logoImg), + def navBarRight = div( desktopOnly, div( - cls := "ml-10 flex items-baseline space-x-4", - pageLinks() + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile ) ) - ) - private def navBarRight = - div( - desktopOnly, + def navBarMobile = div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile + cls := "-mr-2 flex", + mobileOnly, + mobileMenuButton ) - ) - private def navBarMobile = - div( - cls := "-mr-2 flex", - mobileOnly, - mobileMenuButton - ) - - private def navBar = - div( - cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + def navBar = div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile + cls := "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", + div( + cls := "flex items-center justify-between h-16", + navBarLeft, + navBarRight, + navBarMobile + ) ) - ) - private def mobileMenu = - div( - mobileOnly, - cls <-- mobileMenuOpen.signal.map { o => - if (o) "block" else "hidden" - }, - idAttr := "mobile-menu", + def mobileMenu = div( - cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", - pageLinks(cls := "block") - ), - mobileProfile - ) + mobileOnly, + cls <-- mobileMenuOpen.signal.map { o => + if (o) "block" else "hidden" + }, + idAttr := "mobile-menu", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + children <-- pageLinks(cls := "block") + ), + mobileProfile + ) + + nav(cls := "bg-indigo-600", navBar, mobileMenu) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala index 122dfd2..e1b65e5 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -2,19 +2,17 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.Page -import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.waypoint.Router -trait PageHeader: - self: Breadcrumbs with Navigator[Page] => - - def pageHeader: HtmlElement = +object PageHeader: + def render(using Router[Page]): HtmlElement = header( cls := "bg-white shadow-sm", div( cls := "max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8", h1( cls := "text-lg leading-6 font-semibold text-gray-900", - breadcrumbs + Breadcrumbs() ) ) ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala index 006efa0..3ee6483 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -1,23 +1,25 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.Page -trait PageLayout { - def navigation: HtmlElement - def pageHeader: HtmlElement - +object PageLayout: + case class ViewModel( + navigation: NavigationBar.ViewModel, + content: Option[HtmlElement] + ) def render( - $m: Signal[Option[HtmlElement]], + $m: Signal[ViewModel], mods: Modifier[HtmlElement]* - ): HtmlElement = - val $maybeContent = $m.split(_ => ())((_, c, _) => c) + )(using Router[Page]): HtmlElement = + val $maybeContent = $m.map(_.content).split(_ => ())((_, c, _) => c) div( cls := "min-h-full", - navigation, - pageHeader, + NavigationBar.render($m.map(_.navigation)), + PageHeader.render, main( mods, child <-- $maybeContent.map(_.getOrElse(Loading)) ) ) -} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala index 0f89d66..6d2f77b 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DashboardPageConnector.scala @@ -8,4 +8,4 @@ class DashboardPageConnector(using router: Router[Page]): def render: HtmlElement = - AppPage().render(Val(Some(DashboardPage.render))) + AppPage.render(Val(Some(DashboardPage.render))) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala index 31e6cc7..3af1a13 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailKriteriaPageConnector.scala @@ -45,7 +45,7 @@ ) def render: HtmlElement = - AppPage().render( + AppPage.render( $merged.map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailKriteriaPage.render(s)), $pageChangeSignal --> state.actionBus diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala index 3520378..88cc209 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailPageConnector.scala @@ -35,7 +35,7 @@ val $params = state.parameters.startWithNone def render: HtmlElement = - AppPage().render( + AppPage.render( $data.combineWithFn($params)(_ zip _) .map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailPage.render(s)), diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala index a238c5b..d0836ee 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DetailParametruPageConnector.scala @@ -41,7 +41,7 @@ ) def render: HtmlElement = - AppPage().render( + AppPage.render( $merged.map(_.map(buildModel)) .split(_ => ())((_, _, s) => DetailParametruPage.render(s)), $pageChangeSignal --> state.actionBus diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DirectoryPageConnector.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DirectoryPageConnector.scala index 92d8172..72af2d4 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DirectoryPageConnector.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/connectors/DirectoryPageConnector.scala @@ -15,7 +15,7 @@ val $actionSignal = EventStream.fromValue(FetchDirectory) def render: HtmlElement = - AppPage().render( + AppPage.render( $data.split(_ => ())((_, _, s) => pages.directory.DirectoryPage.render( s.map( diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/detail/components/DetailKriteria.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/detail/components/DetailKriteria.scala index 39aad7d..1e2c30d 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/detail/components/DetailKriteria.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/detail/components/DetailKriteria.scala @@ -1,11 +1,167 @@ package cz.e_bs.cmi.mdr.pdb.app.pages.detail.components import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.components.CustomAttrs object DetailKriteria: type ViewModel = SeznamKriterii.Kriterium def render($m: Signal[ViewModel]): HtmlElement = div( h3(cls := "text-l font-bold text-gray-900"), - child.text <-- $m.map(_.nazev) + child.text <-- $m.map(_.nazev), + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + """Applicant Information""" + ), + p( + cls := "mt-1 max-w-2xl text-sm text-gray-500", + """Personal details and application.""" + ) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:px-6", + dl( + cls := "grid grid-cols-1 gap-x-4 gap-y-8 sm:grid-cols-2", + div( + cls := "sm:col-span-1", + dt( + cls := "text-sm font-medium text-gray-500", + """Full name""" + ), + dd( + cls := "mt-1 text-sm text-gray-900", + """Margot Foster""" + ) + ), + div( + cls := "sm:col-span-1", + dt( + cls := "text-sm font-medium text-gray-500", + """Application for""" + ), + dd( + cls := "mt-1 text-sm text-gray-900", + """Backend Developer""" + ) + ), + div( + cls := "sm:col-span-1", + dt( + cls := "text-sm font-medium text-gray-500", + """Email address""" + ), + dd( + cls := "mt-1 text-sm text-gray-900", + """margotfoster@example.com""" + ) + ), + div( + cls := "sm:col-span-1", + dt( + cls := "text-sm font-medium text-gray-500", + """Salary expectation""" + ), + dd( + cls := "mt-1 text-sm text-gray-900", + """$120,000""" + ) + ), + div( + cls := "sm:col-span-2", + dt( + cls := "text-sm font-medium text-gray-500", + """About""" + ), + dd( + cls := "mt-1 text-sm text-gray-900", + """Fugiat ipsum ipsum deserunt culpa aute sint do nostrud anim incididunt cillum culpa consequat. Excepteur qui ipsum aliquip consequat sint. Sit id mollit nulla mollit nostrud in ea officia proident. Irure nostrud pariatur mollit ad adipisicing reprehenderit deserunt qui eu.""" + ) + ), + div( + cls := "sm:col-span-2", + dt( + cls := "text-sm font-medium text-gray-500", + """Attachments""" + ), + dd( + cls := "mt-1 text-sm text-gray-900", + ul( + role := "list", + cls := "border border-gray-200 rounded-md divide-y divide-gray-200", + li( + cls := "pl-3 pr-4 py-3 flex items-center justify-between text-sm", + div( + cls := "w-0 flex-1 flex items-center", { + import svg.* + import CustomAttrs.svg.ariaHidden + svg( + cls := "flex-shrink-0 h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + ariaHidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + }, + span( + cls := "ml-2 flex-1 w-0 truncate", + """resume_back_end_developer.pdf""" + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := "#", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + """Download""" + ) + ) + ), + li( + cls := "pl-3 pr-4 py-3 flex items-center justify-between text-sm", + div( + cls := "w-0 flex-1 flex items-center", { + import svg.* + import CustomAttrs.svg.ariaHidden + svg( + cls := "flex-shrink-0 h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + ariaHidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + }, + span( + cls := "ml-2 flex-1 w-0 truncate", + """coverletter_back_end_developer.pdf""" + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := "#", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + """Download""" + ) + ) + ) + ) + ) + ) + ) + ) + ) ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/waypoint/components/Navigator.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/waypoint/components/Navigator.scala index 8b9edd7..6dfc69d 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/waypoint/components/Navigator.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/waypoint/components/Navigator.scala @@ -4,12 +4,39 @@ import com.raquo.waypoint.Router import org.scalajs.dom import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent +import com.raquo.laminar.nodes.ReactiveElement trait Navigator[P](using router: Router[P]): def navigateTo(page: P): Binder[HtmlElement] = Navigator.navigateTo[P](page) // TODO: replace router NavigateTo action object Navigator { + def navigateTo[P]($page: Signal[P])(using + router: Router[P] + ): Binder[HtmlElement] = + Binder { el => + + val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] + + if (isLinkElement) { + el.amend(href <-- $page.map(page => router.absoluteUrlForPage(page))) + } + + // If element is a link and user is holding a modifier while clicking: + // - Do nothing, browser will open the URL in new tab / window / etc. depending on the modifier key + // Otherwise: + // - Perform regular pushState transition + ( + composeEvents( + onClick + .filter(ev => + !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) + ) + .preventDefault + )(_.sample($page)) --> (p => router.pushState(p)) + ).bind(el) + } + def navigateTo[P](page: P)(using router: Router[P]): Binder[HtmlElement] = Binder { el =>