diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala deleted file mode 100644 index 7e8312e..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala +++ /dev/null @@ -1,40 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router - -def PageHeader(using router: 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 - ) - ) - ) - -def MainSection(mods: Modifier[HtmlElement]*): HtmlElement = - main(mods) - -def Layout( - logo: Navigation.Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[Navigation.MenuItem]], - content: HtmlElement -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full", - Navigation( - logo, - profile, - pages, - userMenu - ), - PageHeader, - content - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala deleted file mode 100644 index 7e8312e..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala +++ /dev/null @@ -1,40 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router - -def PageHeader(using router: 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 - ) - ) - ) - -def MainSection(mods: Modifier[HtmlElement]*): HtmlElement = - main(mods) - -def Layout( - logo: Navigation.Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[Navigation.MenuItem]], - content: HtmlElement -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full", - Navigation( - logo, - profile, - pages, - userMenu - ), - PageHeader, - content - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala deleted file mode 100644 index e518682..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala +++ /dev/null @@ -1,256 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo - -object Navigation: - - case class Logo(name: String, img: String) - - case class MenuItem(title: String) - - given Conversion[Navigation, HtmlElement] = _.render - -import Navigation._ - -case class Navigation( - logo: Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[MenuItem]] -)(using router: Router[Page]): - val mobileMenuOpen = Var(false) - - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - - val desktopOnly = cls("hidden md:block") - val mobileOnly = cls("md:hidden") - - def render: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - 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 - ) - - private inline def avatar(size: Int = 8) = - profile.map(_.userInfo.img match { - case Some(url) => - img( - cls := s"w-$size h-$size rounded-full", - src := url, - alt := "" - ) - case None => - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", - Icons.outline.user(size - 2) - ) - }) - - private def userProfile: HtmlElement = - val menuOpen = 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 - ) - - 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 <-- avatar(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) - ), - /* - * */ - 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 <-- userMenu.map(_.zipWithIndex.map(menuItem)) - ) - ) - - 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 - ) - - div( - cls := "pt-4 pb-3 border-t border-indigo-700", - div( - cls := "flex items-center px-5", - div( - cls := "flex-shrink-0", - child <-- avatar(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- profile.map(_.userInfo.name) - ), - div( - cls := "text-sm font-medium text-indigo-300", - child.text <-- profile.map(_.userInfo.email) - ) - ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - children <-- userMenu.map(_.map(menuItem)) - ) - ) - - private def pageLink(page: Page, active: Signal[Boolean])(using - router: Router[Page] - ): Anchor = - a( - href := router.absoluteUrlForPage(page), - 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 - ) - - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = pages.map( - _.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), - div( - desktopOnly, - div( - cls := "ml-10 flex items-baseline space-x-4", - children <-- pageLinks() - ) - ) - ) - - private def navBarRight = - div( - desktopOnly, - div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile - ) - ) - - 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", - div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile - ) - ) - - def mobileMenu = - div( - 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 - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala deleted file mode 100644 index 7e8312e..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala +++ /dev/null @@ -1,40 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router - -def PageHeader(using router: 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 - ) - ) - ) - -def MainSection(mods: Modifier[HtmlElement]*): HtmlElement = - main(mods) - -def Layout( - logo: Navigation.Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[Navigation.MenuItem]], - content: HtmlElement -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full", - Navigation( - logo, - profile, - pages, - userMenu - ), - PageHeader, - content - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala deleted file mode 100644 index e518682..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala +++ /dev/null @@ -1,256 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo - -object Navigation: - - case class Logo(name: String, img: String) - - case class MenuItem(title: String) - - given Conversion[Navigation, HtmlElement] = _.render - -import Navigation._ - -case class Navigation( - logo: Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[MenuItem]] -)(using router: Router[Page]): - val mobileMenuOpen = Var(false) - - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - - val desktopOnly = cls("hidden md:block") - val mobileOnly = cls("md:hidden") - - def render: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - 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 - ) - - private inline def avatar(size: Int = 8) = - profile.map(_.userInfo.img match { - case Some(url) => - img( - cls := s"w-$size h-$size rounded-full", - src := url, - alt := "" - ) - case None => - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", - Icons.outline.user(size - 2) - ) - }) - - private def userProfile: HtmlElement = - val menuOpen = 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 - ) - - 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 <-- avatar(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) - ), - /* - * */ - 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 <-- userMenu.map(_.zipWithIndex.map(menuItem)) - ) - ) - - 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 - ) - - div( - cls := "pt-4 pb-3 border-t border-indigo-700", - div( - cls := "flex items-center px-5", - div( - cls := "flex-shrink-0", - child <-- avatar(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- profile.map(_.userInfo.name) - ), - div( - cls := "text-sm font-medium text-indigo-300", - child.text <-- profile.map(_.userInfo.email) - ) - ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - children <-- userMenu.map(_.map(menuItem)) - ) - ) - - private def pageLink(page: Page, active: Signal[Boolean])(using - router: Router[Page] - ): Anchor = - a( - href := router.absoluteUrlForPage(page), - 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 - ) - - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = pages.map( - _.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), - div( - desktopOnly, - div( - cls := "ml-10 flex items-baseline space-x-4", - children <-- pageLinks() - ) - ) - ) - - private def navBarRight = - div( - desktopOnly, - div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile - ) - ) - - 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", - div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile - ) - ) - - def mobileMenu = - div( - 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 - ) 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 new file mode 100644 index 0000000..593d88f --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -0,0 +1,236 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import CustomAttrs.ariaCurrent +import com.raquo.waypoint.Router + +trait NavigationBar[Page](using router: Router[Page]): + self: Navigator[Page] => + + case class Logo(img: String, name: String) + case class MenuItem(title: String) + case class UserInfo(name: String, email: String, img: Option[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 + ) + + private def userProfile: HtmlElement = + val menuOpen = 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 + ) + + 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 + ) + ), + /* + * */ + 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) + ) + ) + + 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 + ) + + 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) + ), + div( + cls := "text-sm font-medium text-indigo-300", + child.text <-- $userInfo.map(_.email) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + userMenu.map(menuItem) + ) + ) + + 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) + ) + + 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), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + pageLinks() + ) + ) + ) + + private def navBarRight = + div( + desktopOnly, + div( + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile + ) + ) + + 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", + 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", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + pageLinks(cls := "block") + ), + mobileProfile + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala deleted file mode 100644 index 7e8312e..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala +++ /dev/null @@ -1,40 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router - -def PageHeader(using router: 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 - ) - ) - ) - -def MainSection(mods: Modifier[HtmlElement]*): HtmlElement = - main(mods) - -def Layout( - logo: Navigation.Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[Navigation.MenuItem]], - content: HtmlElement -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full", - Navigation( - logo, - profile, - pages, - userMenu - ), - PageHeader, - content - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala deleted file mode 100644 index e518682..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala +++ /dev/null @@ -1,256 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo - -object Navigation: - - case class Logo(name: String, img: String) - - case class MenuItem(title: String) - - given Conversion[Navigation, HtmlElement] = _.render - -import Navigation._ - -case class Navigation( - logo: Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[MenuItem]] -)(using router: Router[Page]): - val mobileMenuOpen = Var(false) - - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - - val desktopOnly = cls("hidden md:block") - val mobileOnly = cls("md:hidden") - - def render: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - 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 - ) - - private inline def avatar(size: Int = 8) = - profile.map(_.userInfo.img match { - case Some(url) => - img( - cls := s"w-$size h-$size rounded-full", - src := url, - alt := "" - ) - case None => - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", - Icons.outline.user(size - 2) - ) - }) - - private def userProfile: HtmlElement = - val menuOpen = 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 - ) - - 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 <-- avatar(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) - ), - /* - * */ - 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 <-- userMenu.map(_.zipWithIndex.map(menuItem)) - ) - ) - - 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 - ) - - div( - cls := "pt-4 pb-3 border-t border-indigo-700", - div( - cls := "flex items-center px-5", - div( - cls := "flex-shrink-0", - child <-- avatar(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- profile.map(_.userInfo.name) - ), - div( - cls := "text-sm font-medium text-indigo-300", - child.text <-- profile.map(_.userInfo.email) - ) - ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - children <-- userMenu.map(_.map(menuItem)) - ) - ) - - private def pageLink(page: Page, active: Signal[Boolean])(using - router: Router[Page] - ): Anchor = - a( - href := router.absoluteUrlForPage(page), - 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 - ) - - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = pages.map( - _.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), - div( - desktopOnly, - div( - cls := "ml-10 flex items-baseline space-x-4", - children <-- pageLinks() - ) - ) - ) - - private def navBarRight = - div( - desktopOnly, - div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile - ) - ) - - 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", - div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile - ) - ) - - def mobileMenu = - div( - 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 - ) 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 new file mode 100644 index 0000000..593d88f --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -0,0 +1,236 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import CustomAttrs.ariaCurrent +import com.raquo.waypoint.Router + +trait NavigationBar[Page](using router: Router[Page]): + self: Navigator[Page] => + + case class Logo(img: String, name: String) + case class MenuItem(title: String) + case class UserInfo(name: String, email: String, img: Option[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 + ) + + private def userProfile: HtmlElement = + val menuOpen = 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 + ) + + 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 + ) + ), + /* + * */ + 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) + ) + ) + + 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 + ) + + 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) + ), + div( + cls := "text-sm font-medium text-indigo-300", + child.text <-- $userInfo.map(_.email) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + userMenu.map(menuItem) + ) + ) + + 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) + ) + + 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), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + pageLinks() + ) + ) + ) + + private def navBarRight = + div( + desktopOnly, + div( + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile + ) + ) + + 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", + 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", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + pageLinks(cls := "block") + ), + mobileProfile + ) 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 new file mode 100644 index 0000000..122dfd2 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -0,0 +1,20 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +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 + +trait PageHeader: + self: Breadcrumbs with Navigator[Page] => + + def pageHeader: 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 + ) + ) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala deleted file mode 100644 index 7e8312e..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala +++ /dev/null @@ -1,40 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router - -def PageHeader(using router: 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 - ) - ) - ) - -def MainSection(mods: Modifier[HtmlElement]*): HtmlElement = - main(mods) - -def Layout( - logo: Navigation.Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[Navigation.MenuItem]], - content: HtmlElement -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full", - Navigation( - logo, - profile, - pages, - userMenu - ), - PageHeader, - content - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala deleted file mode 100644 index e518682..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala +++ /dev/null @@ -1,256 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo - -object Navigation: - - case class Logo(name: String, img: String) - - case class MenuItem(title: String) - - given Conversion[Navigation, HtmlElement] = _.render - -import Navigation._ - -case class Navigation( - logo: Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[MenuItem]] -)(using router: Router[Page]): - val mobileMenuOpen = Var(false) - - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - - val desktopOnly = cls("hidden md:block") - val mobileOnly = cls("md:hidden") - - def render: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - 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 - ) - - private inline def avatar(size: Int = 8) = - profile.map(_.userInfo.img match { - case Some(url) => - img( - cls := s"w-$size h-$size rounded-full", - src := url, - alt := "" - ) - case None => - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", - Icons.outline.user(size - 2) - ) - }) - - private def userProfile: HtmlElement = - val menuOpen = 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 - ) - - 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 <-- avatar(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) - ), - /* - * */ - 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 <-- userMenu.map(_.zipWithIndex.map(menuItem)) - ) - ) - - 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 - ) - - div( - cls := "pt-4 pb-3 border-t border-indigo-700", - div( - cls := "flex items-center px-5", - div( - cls := "flex-shrink-0", - child <-- avatar(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- profile.map(_.userInfo.name) - ), - div( - cls := "text-sm font-medium text-indigo-300", - child.text <-- profile.map(_.userInfo.email) - ) - ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - children <-- userMenu.map(_.map(menuItem)) - ) - ) - - private def pageLink(page: Page, active: Signal[Boolean])(using - router: Router[Page] - ): Anchor = - a( - href := router.absoluteUrlForPage(page), - 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 - ) - - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = pages.map( - _.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), - div( - desktopOnly, - div( - cls := "ml-10 flex items-baseline space-x-4", - children <-- pageLinks() - ) - ) - ) - - private def navBarRight = - div( - desktopOnly, - div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile - ) - ) - - 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", - div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile - ) - ) - - def mobileMenu = - div( - 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 - ) 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 new file mode 100644 index 0000000..593d88f --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -0,0 +1,236 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import CustomAttrs.ariaCurrent +import com.raquo.waypoint.Router + +trait NavigationBar[Page](using router: Router[Page]): + self: Navigator[Page] => + + case class Logo(img: String, name: String) + case class MenuItem(title: String) + case class UserInfo(name: String, email: String, img: Option[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 + ) + + private def userProfile: HtmlElement = + val menuOpen = 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 + ) + + 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 + ) + ), + /* + * */ + 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) + ) + ) + + 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 + ) + + 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) + ), + div( + cls := "text-sm font-medium text-indigo-300", + child.text <-- $userInfo.map(_.email) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + userMenu.map(menuItem) + ) + ) + + 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) + ) + + 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), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + pageLinks() + ) + ) + ) + + private def navBarRight = + div( + desktopOnly, + div( + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile + ) + ) + + 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", + 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", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + pageLinks(cls := "block") + ), + mobileProfile + ) 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 new file mode 100644 index 0000000..122dfd2 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -0,0 +1,20 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +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 + +trait PageHeader: + self: Breadcrumbs with Navigator[Page] => + + def pageHeader: 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 + ) + ) + ) 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 new file mode 100644 index 0000000..fcb89aa --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -0,0 +1,17 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} + +trait PageLayout { + def navigation: HtmlElement + def pageHeader: HtmlElement + def pageContent: HtmlElement + + def render: HtmlElement = + div( + cls := "min-h-full", + navigation, + pageHeader, + main(pageContent) + ) +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala deleted file mode 100644 index 7e8312e..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala +++ /dev/null @@ -1,40 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router - -def PageHeader(using router: 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 - ) - ) - ) - -def MainSection(mods: Modifier[HtmlElement]*): HtmlElement = - main(mods) - -def Layout( - logo: Navigation.Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[Navigation.MenuItem]], - content: HtmlElement -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full", - Navigation( - logo, - profile, - pages, - userMenu - ), - PageHeader, - content - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala deleted file mode 100644 index e518682..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala +++ /dev/null @@ -1,256 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo - -object Navigation: - - case class Logo(name: String, img: String) - - case class MenuItem(title: String) - - given Conversion[Navigation, HtmlElement] = _.render - -import Navigation._ - -case class Navigation( - logo: Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[MenuItem]] -)(using router: Router[Page]): - val mobileMenuOpen = Var(false) - - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - - val desktopOnly = cls("hidden md:block") - val mobileOnly = cls("md:hidden") - - def render: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - 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 - ) - - private inline def avatar(size: Int = 8) = - profile.map(_.userInfo.img match { - case Some(url) => - img( - cls := s"w-$size h-$size rounded-full", - src := url, - alt := "" - ) - case None => - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", - Icons.outline.user(size - 2) - ) - }) - - private def userProfile: HtmlElement = - val menuOpen = 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 - ) - - 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 <-- avatar(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) - ), - /* - * */ - 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 <-- userMenu.map(_.zipWithIndex.map(menuItem)) - ) - ) - - 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 - ) - - div( - cls := "pt-4 pb-3 border-t border-indigo-700", - div( - cls := "flex items-center px-5", - div( - cls := "flex-shrink-0", - child <-- avatar(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- profile.map(_.userInfo.name) - ), - div( - cls := "text-sm font-medium text-indigo-300", - child.text <-- profile.map(_.userInfo.email) - ) - ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - children <-- userMenu.map(_.map(menuItem)) - ) - ) - - private def pageLink(page: Page, active: Signal[Boolean])(using - router: Router[Page] - ): Anchor = - a( - href := router.absoluteUrlForPage(page), - 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 - ) - - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = pages.map( - _.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), - div( - desktopOnly, - div( - cls := "ml-10 flex items-baseline space-x-4", - children <-- pageLinks() - ) - ) - ) - - private def navBarRight = - div( - desktopOnly, - div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile - ) - ) - - 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", - div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile - ) - ) - - def mobileMenu = - div( - 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 - ) 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 new file mode 100644 index 0000000..593d88f --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -0,0 +1,236 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import CustomAttrs.ariaCurrent +import com.raquo.waypoint.Router + +trait NavigationBar[Page](using router: Router[Page]): + self: Navigator[Page] => + + case class Logo(img: String, name: String) + case class MenuItem(title: String) + case class UserInfo(name: String, email: String, img: Option[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 + ) + + private def userProfile: HtmlElement = + val menuOpen = 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 + ) + + 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 + ) + ), + /* + * */ + 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) + ) + ) + + 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 + ) + + 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) + ), + div( + cls := "text-sm font-medium text-indigo-300", + child.text <-- $userInfo.map(_.email) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + userMenu.map(menuItem) + ) + ) + + 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) + ) + + 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), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + pageLinks() + ) + ) + ) + + private def navBarRight = + div( + desktopOnly, + div( + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile + ) + ) + + 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", + 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", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + pageLinks(cls := "block") + ), + mobileProfile + ) 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 new file mode 100644 index 0000000..122dfd2 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -0,0 +1,20 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +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 + +trait PageHeader: + self: Breadcrumbs with Navigator[Page] => + + def pageHeader: 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 + ) + ) + ) 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 new file mode 100644 index 0000000..fcb89aa --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -0,0 +1,17 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} + +trait PageLayout { + def navigation: HtmlElement + def pageHeader: HtmlElement + def pageContent: HtmlElement + + def render: HtmlElement = + div( + cls := "min-h-full", + navigation, + pageHeader, + main(pageContent) + ) +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala new file mode 100644 index 0000000..9e8be6d --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala @@ -0,0 +1,55 @@ +package cz.e_bs.cmi.mdr.pdb.app.components.list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.Icons +import cz.e_bs.cmi.mdr.pdb.app.Routes +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait BaseList[RowData]: + + type RenderRow = Signal[RowData] => Modifier[HtmlElement] + + inline protected def containerElement: HtmlTag[dom.html.Element] = a + + protected val containerMods: RenderRow + protected val title: RenderRow + protected val topRight: RenderRow + protected val bottomLeft: RenderRow + protected val bottomRight: RenderRow + + def row($data: Signal[RowData]) = + li( + containerElement( + containerMods($data), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + title($data) + ), + div( + cls := "ml-2 flex-shrink-0 flex", + topRight($data) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + bottomLeft($data), + bottomRight($data) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala deleted file mode 100644 index 7e8312e..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala +++ /dev/null @@ -1,40 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router - -def PageHeader(using router: 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 - ) - ) - ) - -def MainSection(mods: Modifier[HtmlElement]*): HtmlElement = - main(mods) - -def Layout( - logo: Navigation.Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[Navigation.MenuItem]], - content: HtmlElement -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full", - Navigation( - logo, - profile, - pages, - userMenu - ), - PageHeader, - content - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala deleted file mode 100644 index e518682..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala +++ /dev/null @@ -1,256 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo - -object Navigation: - - case class Logo(name: String, img: String) - - case class MenuItem(title: String) - - given Conversion[Navigation, HtmlElement] = _.render - -import Navigation._ - -case class Navigation( - logo: Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[MenuItem]] -)(using router: Router[Page]): - val mobileMenuOpen = Var(false) - - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - - val desktopOnly = cls("hidden md:block") - val mobileOnly = cls("md:hidden") - - def render: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - 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 - ) - - private inline def avatar(size: Int = 8) = - profile.map(_.userInfo.img match { - case Some(url) => - img( - cls := s"w-$size h-$size rounded-full", - src := url, - alt := "" - ) - case None => - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", - Icons.outline.user(size - 2) - ) - }) - - private def userProfile: HtmlElement = - val menuOpen = 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 - ) - - 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 <-- avatar(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) - ), - /* - * */ - 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 <-- userMenu.map(_.zipWithIndex.map(menuItem)) - ) - ) - - 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 - ) - - div( - cls := "pt-4 pb-3 border-t border-indigo-700", - div( - cls := "flex items-center px-5", - div( - cls := "flex-shrink-0", - child <-- avatar(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- profile.map(_.userInfo.name) - ), - div( - cls := "text-sm font-medium text-indigo-300", - child.text <-- profile.map(_.userInfo.email) - ) - ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - children <-- userMenu.map(_.map(menuItem)) - ) - ) - - private def pageLink(page: Page, active: Signal[Boolean])(using - router: Router[Page] - ): Anchor = - a( - href := router.absoluteUrlForPage(page), - 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 - ) - - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = pages.map( - _.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), - div( - desktopOnly, - div( - cls := "ml-10 flex items-baseline space-x-4", - children <-- pageLinks() - ) - ) - ) - - private def navBarRight = - div( - desktopOnly, - div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile - ) - ) - - 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", - div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile - ) - ) - - def mobileMenu = - div( - 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 - ) 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 new file mode 100644 index 0000000..593d88f --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -0,0 +1,236 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import CustomAttrs.ariaCurrent +import com.raquo.waypoint.Router + +trait NavigationBar[Page](using router: Router[Page]): + self: Navigator[Page] => + + case class Logo(img: String, name: String) + case class MenuItem(title: String) + case class UserInfo(name: String, email: String, img: Option[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 + ) + + private def userProfile: HtmlElement = + val menuOpen = 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 + ) + + 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 + ) + ), + /* + * */ + 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) + ) + ) + + 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 + ) + + 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) + ), + div( + cls := "text-sm font-medium text-indigo-300", + child.text <-- $userInfo.map(_.email) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + userMenu.map(menuItem) + ) + ) + + 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) + ) + + 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), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + pageLinks() + ) + ) + ) + + private def navBarRight = + div( + desktopOnly, + div( + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile + ) + ) + + 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", + 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", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + pageLinks(cls := "block") + ), + mobileProfile + ) 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 new file mode 100644 index 0000000..122dfd2 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -0,0 +1,20 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +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 + +trait PageHeader: + self: Breadcrumbs with Navigator[Page] => + + def pageHeader: 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 + ) + ) + ) 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 new file mode 100644 index 0000000..fcb89aa --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -0,0 +1,17 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} + +trait PageLayout { + def navigation: HtmlElement + def pageHeader: HtmlElement + def pageContent: HtmlElement + + def render: HtmlElement = + div( + cls := "min-h-full", + navigation, + pageHeader, + main(pageContent) + ) +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala new file mode 100644 index 0000000..9e8be6d --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala @@ -0,0 +1,55 @@ +package cz.e_bs.cmi.mdr.pdb.app.components.list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.Icons +import cz.e_bs.cmi.mdr.pdb.app.Routes +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait BaseList[RowData]: + + type RenderRow = Signal[RowData] => Modifier[HtmlElement] + + inline protected def containerElement: HtmlTag[dom.html.Element] = a + + protected val containerMods: RenderRow + protected val title: RenderRow + protected val topRight: RenderRow + protected val bottomLeft: RenderRow + protected val bottomRight: RenderRow + + def row($data: Signal[RowData]) = + li( + containerElement( + containerMods($data), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + title($data) + ), + div( + cls := "ml-2 flex-shrink-0 flex", + topRight($data) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + bottomLeft($data), + bottomRight($data) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala index fdf353c..cc59bee 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala @@ -1,6 +1,10 @@ package cz.e_bs.cmi.mdr.pdb.app.pages import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage +import cz.e_bs.cmi.mdr.pdb.app.Page -def DashboardPage: HtmlElement = - div("Dashboard page") +class DashboardPage(using router: Router[Page]) extends AppPage: + override def pageContent: HtmlElement = + div("Dashboard page") diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala deleted file mode 100644 index 7e8312e..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala +++ /dev/null @@ -1,40 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router - -def PageHeader(using router: 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 - ) - ) - ) - -def MainSection(mods: Modifier[HtmlElement]*): HtmlElement = - main(mods) - -def Layout( - logo: Navigation.Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[Navigation.MenuItem]], - content: HtmlElement -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full", - Navigation( - logo, - profile, - pages, - userMenu - ), - PageHeader, - content - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala deleted file mode 100644 index e518682..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala +++ /dev/null @@ -1,256 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo - -object Navigation: - - case class Logo(name: String, img: String) - - case class MenuItem(title: String) - - given Conversion[Navigation, HtmlElement] = _.render - -import Navigation._ - -case class Navigation( - logo: Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[MenuItem]] -)(using router: Router[Page]): - val mobileMenuOpen = Var(false) - - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - - val desktopOnly = cls("hidden md:block") - val mobileOnly = cls("md:hidden") - - def render: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - 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 - ) - - private inline def avatar(size: Int = 8) = - profile.map(_.userInfo.img match { - case Some(url) => - img( - cls := s"w-$size h-$size rounded-full", - src := url, - alt := "" - ) - case None => - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", - Icons.outline.user(size - 2) - ) - }) - - private def userProfile: HtmlElement = - val menuOpen = 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 - ) - - 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 <-- avatar(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) - ), - /* - * */ - 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 <-- userMenu.map(_.zipWithIndex.map(menuItem)) - ) - ) - - 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 - ) - - div( - cls := "pt-4 pb-3 border-t border-indigo-700", - div( - cls := "flex items-center px-5", - div( - cls := "flex-shrink-0", - child <-- avatar(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- profile.map(_.userInfo.name) - ), - div( - cls := "text-sm font-medium text-indigo-300", - child.text <-- profile.map(_.userInfo.email) - ) - ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - children <-- userMenu.map(_.map(menuItem)) - ) - ) - - private def pageLink(page: Page, active: Signal[Boolean])(using - router: Router[Page] - ): Anchor = - a( - href := router.absoluteUrlForPage(page), - 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 - ) - - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = pages.map( - _.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), - div( - desktopOnly, - div( - cls := "ml-10 flex items-baseline space-x-4", - children <-- pageLinks() - ) - ) - ) - - private def navBarRight = - div( - desktopOnly, - div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile - ) - ) - - 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", - div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile - ) - ) - - def mobileMenu = - div( - 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 - ) 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 new file mode 100644 index 0000000..593d88f --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -0,0 +1,236 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import CustomAttrs.ariaCurrent +import com.raquo.waypoint.Router + +trait NavigationBar[Page](using router: Router[Page]): + self: Navigator[Page] => + + case class Logo(img: String, name: String) + case class MenuItem(title: String) + case class UserInfo(name: String, email: String, img: Option[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 + ) + + private def userProfile: HtmlElement = + val menuOpen = 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 + ) + + 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 + ) + ), + /* + * */ + 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) + ) + ) + + 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 + ) + + 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) + ), + div( + cls := "text-sm font-medium text-indigo-300", + child.text <-- $userInfo.map(_.email) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + userMenu.map(menuItem) + ) + ) + + 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) + ) + + 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), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + pageLinks() + ) + ) + ) + + private def navBarRight = + div( + desktopOnly, + div( + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile + ) + ) + + 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", + 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", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + pageLinks(cls := "block") + ), + mobileProfile + ) 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 new file mode 100644 index 0000000..122dfd2 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -0,0 +1,20 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +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 + +trait PageHeader: + self: Breadcrumbs with Navigator[Page] => + + def pageHeader: 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 + ) + ) + ) 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 new file mode 100644 index 0000000..fcb89aa --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -0,0 +1,17 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} + +trait PageLayout { + def navigation: HtmlElement + def pageHeader: HtmlElement + def pageContent: HtmlElement + + def render: HtmlElement = + div( + cls := "min-h-full", + navigation, + pageHeader, + main(pageContent) + ) +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala new file mode 100644 index 0000000..9e8be6d --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala @@ -0,0 +1,55 @@ +package cz.e_bs.cmi.mdr.pdb.app.components.list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.Icons +import cz.e_bs.cmi.mdr.pdb.app.Routes +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait BaseList[RowData]: + + type RenderRow = Signal[RowData] => Modifier[HtmlElement] + + inline protected def containerElement: HtmlTag[dom.html.Element] = a + + protected val containerMods: RenderRow + protected val title: RenderRow + protected val topRight: RenderRow + protected val bottomLeft: RenderRow + protected val bottomRight: RenderRow + + def row($data: Signal[RowData]) = + li( + containerElement( + containerMods($data), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + title($data) + ), + div( + cls := "ml-2 flex-shrink-0 flex", + topRight($data) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + bottomLeft($data), + bottomRight($data) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala index fdf353c..cc59bee 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala @@ -1,6 +1,10 @@ package cz.e_bs.cmi.mdr.pdb.app.pages import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage +import cz.e_bs.cmi.mdr.pdb.app.Page -def DashboardPage: HtmlElement = - div("Dashboard page") +class DashboardPage(using router: Router[Page]) extends AppPage: + override def pageContent: HtmlElement = + div("Dashboard page") diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala index d299408..077ece7 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala @@ -9,14 +9,16 @@ import cz.e_bs.cmi.mdr.pdb.app.services.DataFetcher import com.raquo.airstream.core.EventStream import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.CustomAttrs.datetime +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage -val datetime = customHtmlAttr("datetime", StringAsIsCodec) - -def DetailPage(fetch: String => EventStream[Osoba])( +case class DetailPage(fetch: String => EventStream[Osoba])( $page: Signal[Page.Detail] -)(using router: Router[Page]): HtmlElement = +)(using + router: Router[Page] +) extends AppPage: // TODO: proper loader - val loading = + private val loading = div( cls := "bg-gray-50 overflow-hidden rounded-lg", div( @@ -24,206 +26,210 @@ "Loading..." ) ) - val data = Var[Option[Osoba]](None) - val $maybeOsoba = data.signal.split(_ => ())((_, _, s) => OsobaView(s)) - val $fetchedData = $page.splitOne(_.osobniCislo)((osc, _, _) => osc) - .flatMap(fetch) - .debugLog() - div( - cls := "max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8", - $fetchedData --> data.writer.contramapSome, - $fetchedData --> (o => router.replaceState(Page.Detail(o))), - child <-- $maybeOsoba.map(_.getOrElse(loading)) - ) -def OsobaView($osoba: Signal[Osoba]): HtmlElement = - def funkce($fce: Signal[Funkce]) = - p( - cls := "text-sm font-medium text-gray-500", - child.text <-- $fce.map(_.nazev), - span( - cls := "hidden md:inline", - " @ ", - child.text <-- $fce.map(_.stredisko), - ", ", - child.text <-- $fce.map(_.voj) - ) - ) - - def pp($pp: Signal[PracovniPomer]) = - p( - cls := "text-sm font-medium text-gray-500", - child.text <-- $pp.map(_.druh), - " od ", - time( - datetime <-- $pp.map(_.pocatek.toString), - child.text <-- $pp.map(_.pocatek.toString) - ) - ) - - div( - cls := "flex flex-col space-y-4", + override def pageContent: HtmlElement = + val data = Var[Option[Osoba]](None) + val $maybeOsoba = + data.signal.split(_ => ())((_, _, s) => osobaView(s)) + val $fetchedData = $page.splitOne(_.osobniCislo)((osc, _, _) => osc) + .flatMap(fetch) + .debugLog() div( - cls := "md:flex md:items-center md:justify-between md:space-x-5", - div( - cls := "flex items-start space-x-5", - div( - cls := "flex-shrink-0", - Avatar($osoba.map(_.img), 16) - ), - div( - h1( - cls := "text-2xl font-bold text-gray-900", - child.text <-- $osoba.map(_.jmeno) - ), - funkce($osoba.map(_.hlavniFunkce)), - pp($osoba.map(_.pracovniPomer)) + cls := "max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8", + $fetchedData --> data.writer.contramapSome, + $fetchedData --> (o => router.replaceState(Page.Detail(o))), + child <-- $maybeOsoba.map(_.getOrElse(loading)) + ) + + private def osobaView($osoba: Signal[Osoba]): HtmlElement = + def funkce($fce: Signal[Funkce]) = + p( + cls := "text-sm font-medium text-gray-500", + child.text <-- $fce.map(_.nazev), + span( + cls := "hidden md:inline", + " @ ", + child.text <-- $fce.map(_.stredisko), + ", ", + child.text <-- $fce.map(_.voj) ) ) - ), + + def pp($pp: Signal[PracovniPomer]) = + p( + cls := "text-sm font-medium text-gray-500", + child.text <-- $pp.map(_.druh), + " od ", + time( + datetime <-- $pp.map(_.pocatek.toString), + child.text <-- $pp.map(_.pocatek.toString) + ) + ) + div( - cls := "bg-white shadow overflow-hidden sm:rounded-md", - ul( - role := "list", - cls := "divide-y divide-gray-200", - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", + cls := "flex flex-col space-y-4", + div( + cls := "md:flex md:items-center md:justify-between md:space-x-5", + div( + cls := "flex items-start space-x-5", + div( + cls := "flex-shrink-0", + Avatar($osoba.map(_.img)).avatar(16) + ), + div( + h1( + cls := "text-2xl font-bold text-gray-900", + child.text <-- $osoba.map(_.jmeno) + ), + funkce($osoba.map(_.hlavniFunkce)), + pp($osoba.map(_.pracovniPomer)) + ) + ) + ), + div( + cls := "bg-white shadow overflow-hidden sm:rounded-md", + ul( + role := "list", + cls := "divide-y divide-gray-200", + li( + a( + href := "#", + cls := "block hover:bg-gray-50", div( - cls := "min-w-0 flex-1 pr-4", + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + "Komise pro pověřování pracovníků" + ), + div( + cls := "ml-2 flex-shrink-0 flex", + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", + """Splněno""" + ) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div(), + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + Icons.solid.calendar, + p( + """do """, + time( + datetime := "2020-01-07", + "01.07.2020" + ) + ) + ) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ), + li( + a( + href := "#", + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6", div( cls := "flex items-center justify-between", p( cls := "text-sm font-medium text-indigo-600 truncate", - "Komise pro pověřování pracovníků" + """Front End Developer""" ), div( cls := "ml-2 flex-shrink-0 flex", p( cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Splněno""" + """Full-time""" ) ) ), div( cls := "mt-2 sm:flex sm:justify-between", - div(), + div( + cls := "sm:flex", + p( + cls := "flex items-center text-sm text-gray-500", + Icons.solid.users, + """Engineering""" + ), + p( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", + Icons.solid.`location-marker`, + """Remote""" + ) + ), div( cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", Icons.solid.calendar, p( - """do """, + """Closing on""", time( datetime := "2020-01-07", - "01.07.2020" + """January 7, 2020""" ) ) ) ) - ), - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right` ) ) - ) - ), - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6", + ), + li( + a( + href := "#", + cls := "block hover:bg-gray-50", div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - """Front End Developer""" - ), + cls := "px-4 py-4 sm:px-6", div( - cls := "ml-2 flex-shrink-0 flex", + cls := "flex items-center justify-between", p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Full-time""" - ) - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - div( - cls := "sm:flex", - p( - cls := "flex items-center text-sm text-gray-500", - Icons.solid.users, - """Engineering""" + cls := "text-sm font-medium text-indigo-600 truncate", + """User Interface Designer""" ), - p( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", - Icons.solid.`location-marker`, - """Remote""" - ) - ), - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - Icons.solid.calendar, - p( - """Closing on""", - time( - datetime := "2020-01-07", - """January 7, 2020""" + div( + cls := "ml-2 flex-shrink-0 flex", + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", + """Full-time""" ) ) - ) - ) - ) - ) - ), - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - """User Interface Designer""" ), div( - cls := "ml-2 flex-shrink-0 flex", - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Full-time""" - ) - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - div( - cls := "sm:flex", - p( - cls := "flex items-center text-sm text-gray-500", - Icons.solid.users, - """Design""" + cls := "mt-2 sm:flex sm:justify-between", + div( + cls := "sm:flex", + p( + cls := "flex items-center text-sm text-gray-500", + Icons.solid.users, + """Design""" + ), + p( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", + Icons.solid.`location-marker`, + """Remote""" + ) ), - p( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", - Icons.solid.`location-marker`, - """Remote""" - ) - ), - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - Icons.solid.calendar, - p( - """Closing on""", - time( - datetime := "2020-01-14", - """January 14, 2020""" + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + Icons.solid.calendar, + p( + """Closing on""", + time( + datetime := "2020-01-14", + """January 14, 2020""" + ) ) ) ) @@ -233,4 +239,3 @@ ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala deleted file mode 100644 index 7e8312e..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala +++ /dev/null @@ -1,40 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router - -def PageHeader(using router: 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 - ) - ) - ) - -def MainSection(mods: Modifier[HtmlElement]*): HtmlElement = - main(mods) - -def Layout( - logo: Navigation.Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[Navigation.MenuItem]], - content: HtmlElement -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full", - Navigation( - logo, - profile, - pages, - userMenu - ), - PageHeader, - content - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala deleted file mode 100644 index e518682..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala +++ /dev/null @@ -1,256 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo - -object Navigation: - - case class Logo(name: String, img: String) - - case class MenuItem(title: String) - - given Conversion[Navigation, HtmlElement] = _.render - -import Navigation._ - -case class Navigation( - logo: Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[MenuItem]] -)(using router: Router[Page]): - val mobileMenuOpen = Var(false) - - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - - val desktopOnly = cls("hidden md:block") - val mobileOnly = cls("md:hidden") - - def render: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - 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 - ) - - private inline def avatar(size: Int = 8) = - profile.map(_.userInfo.img match { - case Some(url) => - img( - cls := s"w-$size h-$size rounded-full", - src := url, - alt := "" - ) - case None => - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", - Icons.outline.user(size - 2) - ) - }) - - private def userProfile: HtmlElement = - val menuOpen = 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 - ) - - 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 <-- avatar(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) - ), - /* - * */ - 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 <-- userMenu.map(_.zipWithIndex.map(menuItem)) - ) - ) - - 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 - ) - - div( - cls := "pt-4 pb-3 border-t border-indigo-700", - div( - cls := "flex items-center px-5", - div( - cls := "flex-shrink-0", - child <-- avatar(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- profile.map(_.userInfo.name) - ), - div( - cls := "text-sm font-medium text-indigo-300", - child.text <-- profile.map(_.userInfo.email) - ) - ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - children <-- userMenu.map(_.map(menuItem)) - ) - ) - - private def pageLink(page: Page, active: Signal[Boolean])(using - router: Router[Page] - ): Anchor = - a( - href := router.absoluteUrlForPage(page), - 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 - ) - - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = pages.map( - _.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), - div( - desktopOnly, - div( - cls := "ml-10 flex items-baseline space-x-4", - children <-- pageLinks() - ) - ) - ) - - private def navBarRight = - div( - desktopOnly, - div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile - ) - ) - - 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", - div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile - ) - ) - - def mobileMenu = - div( - 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 - ) 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 new file mode 100644 index 0000000..593d88f --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -0,0 +1,236 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import CustomAttrs.ariaCurrent +import com.raquo.waypoint.Router + +trait NavigationBar[Page](using router: Router[Page]): + self: Navigator[Page] => + + case class Logo(img: String, name: String) + case class MenuItem(title: String) + case class UserInfo(name: String, email: String, img: Option[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 + ) + + private def userProfile: HtmlElement = + val menuOpen = 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 + ) + + 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 + ) + ), + /* + * */ + 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) + ) + ) + + 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 + ) + + 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) + ), + div( + cls := "text-sm font-medium text-indigo-300", + child.text <-- $userInfo.map(_.email) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + userMenu.map(menuItem) + ) + ) + + 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) + ) + + 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), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + pageLinks() + ) + ) + ) + + private def navBarRight = + div( + desktopOnly, + div( + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile + ) + ) + + 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", + 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", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + pageLinks(cls := "block") + ), + mobileProfile + ) 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 new file mode 100644 index 0000000..122dfd2 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -0,0 +1,20 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +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 + +trait PageHeader: + self: Breadcrumbs with Navigator[Page] => + + def pageHeader: 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 + ) + ) + ) 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 new file mode 100644 index 0000000..fcb89aa --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -0,0 +1,17 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} + +trait PageLayout { + def navigation: HtmlElement + def pageHeader: HtmlElement + def pageContent: HtmlElement + + def render: HtmlElement = + div( + cls := "min-h-full", + navigation, + pageHeader, + main(pageContent) + ) +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala new file mode 100644 index 0000000..9e8be6d --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala @@ -0,0 +1,55 @@ +package cz.e_bs.cmi.mdr.pdb.app.components.list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.Icons +import cz.e_bs.cmi.mdr.pdb.app.Routes +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait BaseList[RowData]: + + type RenderRow = Signal[RowData] => Modifier[HtmlElement] + + inline protected def containerElement: HtmlTag[dom.html.Element] = a + + protected val containerMods: RenderRow + protected val title: RenderRow + protected val topRight: RenderRow + protected val bottomLeft: RenderRow + protected val bottomRight: RenderRow + + def row($data: Signal[RowData]) = + li( + containerElement( + containerMods($data), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + title($data) + ), + div( + cls := "ml-2 flex-shrink-0 flex", + topRight($data) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + bottomLeft($data), + bottomRight($data) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala index fdf353c..cc59bee 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala @@ -1,6 +1,10 @@ package cz.e_bs.cmi.mdr.pdb.app.pages import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage +import cz.e_bs.cmi.mdr.pdb.app.Page -def DashboardPage: HtmlElement = - div("Dashboard page") +class DashboardPage(using router: Router[Page]) extends AppPage: + override def pageContent: HtmlElement = + div("Dashboard page") diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala index d299408..077ece7 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala @@ -9,14 +9,16 @@ import cz.e_bs.cmi.mdr.pdb.app.services.DataFetcher import com.raquo.airstream.core.EventStream import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.CustomAttrs.datetime +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage -val datetime = customHtmlAttr("datetime", StringAsIsCodec) - -def DetailPage(fetch: String => EventStream[Osoba])( +case class DetailPage(fetch: String => EventStream[Osoba])( $page: Signal[Page.Detail] -)(using router: Router[Page]): HtmlElement = +)(using + router: Router[Page] +) extends AppPage: // TODO: proper loader - val loading = + private val loading = div( cls := "bg-gray-50 overflow-hidden rounded-lg", div( @@ -24,206 +26,210 @@ "Loading..." ) ) - val data = Var[Option[Osoba]](None) - val $maybeOsoba = data.signal.split(_ => ())((_, _, s) => OsobaView(s)) - val $fetchedData = $page.splitOne(_.osobniCislo)((osc, _, _) => osc) - .flatMap(fetch) - .debugLog() - div( - cls := "max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8", - $fetchedData --> data.writer.contramapSome, - $fetchedData --> (o => router.replaceState(Page.Detail(o))), - child <-- $maybeOsoba.map(_.getOrElse(loading)) - ) -def OsobaView($osoba: Signal[Osoba]): HtmlElement = - def funkce($fce: Signal[Funkce]) = - p( - cls := "text-sm font-medium text-gray-500", - child.text <-- $fce.map(_.nazev), - span( - cls := "hidden md:inline", - " @ ", - child.text <-- $fce.map(_.stredisko), - ", ", - child.text <-- $fce.map(_.voj) - ) - ) - - def pp($pp: Signal[PracovniPomer]) = - p( - cls := "text-sm font-medium text-gray-500", - child.text <-- $pp.map(_.druh), - " od ", - time( - datetime <-- $pp.map(_.pocatek.toString), - child.text <-- $pp.map(_.pocatek.toString) - ) - ) - - div( - cls := "flex flex-col space-y-4", + override def pageContent: HtmlElement = + val data = Var[Option[Osoba]](None) + val $maybeOsoba = + data.signal.split(_ => ())((_, _, s) => osobaView(s)) + val $fetchedData = $page.splitOne(_.osobniCislo)((osc, _, _) => osc) + .flatMap(fetch) + .debugLog() div( - cls := "md:flex md:items-center md:justify-between md:space-x-5", - div( - cls := "flex items-start space-x-5", - div( - cls := "flex-shrink-0", - Avatar($osoba.map(_.img), 16) - ), - div( - h1( - cls := "text-2xl font-bold text-gray-900", - child.text <-- $osoba.map(_.jmeno) - ), - funkce($osoba.map(_.hlavniFunkce)), - pp($osoba.map(_.pracovniPomer)) + cls := "max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8", + $fetchedData --> data.writer.contramapSome, + $fetchedData --> (o => router.replaceState(Page.Detail(o))), + child <-- $maybeOsoba.map(_.getOrElse(loading)) + ) + + private def osobaView($osoba: Signal[Osoba]): HtmlElement = + def funkce($fce: Signal[Funkce]) = + p( + cls := "text-sm font-medium text-gray-500", + child.text <-- $fce.map(_.nazev), + span( + cls := "hidden md:inline", + " @ ", + child.text <-- $fce.map(_.stredisko), + ", ", + child.text <-- $fce.map(_.voj) ) ) - ), + + def pp($pp: Signal[PracovniPomer]) = + p( + cls := "text-sm font-medium text-gray-500", + child.text <-- $pp.map(_.druh), + " od ", + time( + datetime <-- $pp.map(_.pocatek.toString), + child.text <-- $pp.map(_.pocatek.toString) + ) + ) + div( - cls := "bg-white shadow overflow-hidden sm:rounded-md", - ul( - role := "list", - cls := "divide-y divide-gray-200", - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", + cls := "flex flex-col space-y-4", + div( + cls := "md:flex md:items-center md:justify-between md:space-x-5", + div( + cls := "flex items-start space-x-5", + div( + cls := "flex-shrink-0", + Avatar($osoba.map(_.img)).avatar(16) + ), + div( + h1( + cls := "text-2xl font-bold text-gray-900", + child.text <-- $osoba.map(_.jmeno) + ), + funkce($osoba.map(_.hlavniFunkce)), + pp($osoba.map(_.pracovniPomer)) + ) + ) + ), + div( + cls := "bg-white shadow overflow-hidden sm:rounded-md", + ul( + role := "list", + cls := "divide-y divide-gray-200", + li( + a( + href := "#", + cls := "block hover:bg-gray-50", div( - cls := "min-w-0 flex-1 pr-4", + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + "Komise pro pověřování pracovníků" + ), + div( + cls := "ml-2 flex-shrink-0 flex", + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", + """Splněno""" + ) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div(), + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + Icons.solid.calendar, + p( + """do """, + time( + datetime := "2020-01-07", + "01.07.2020" + ) + ) + ) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ), + li( + a( + href := "#", + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6", div( cls := "flex items-center justify-between", p( cls := "text-sm font-medium text-indigo-600 truncate", - "Komise pro pověřování pracovníků" + """Front End Developer""" ), div( cls := "ml-2 flex-shrink-0 flex", p( cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Splněno""" + """Full-time""" ) ) ), div( cls := "mt-2 sm:flex sm:justify-between", - div(), + div( + cls := "sm:flex", + p( + cls := "flex items-center text-sm text-gray-500", + Icons.solid.users, + """Engineering""" + ), + p( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", + Icons.solid.`location-marker`, + """Remote""" + ) + ), div( cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", Icons.solid.calendar, p( - """do """, + """Closing on""", time( datetime := "2020-01-07", - "01.07.2020" + """January 7, 2020""" ) ) ) ) - ), - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right` ) ) - ) - ), - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6", + ), + li( + a( + href := "#", + cls := "block hover:bg-gray-50", div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - """Front End Developer""" - ), + cls := "px-4 py-4 sm:px-6", div( - cls := "ml-2 flex-shrink-0 flex", + cls := "flex items-center justify-between", p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Full-time""" - ) - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - div( - cls := "sm:flex", - p( - cls := "flex items-center text-sm text-gray-500", - Icons.solid.users, - """Engineering""" + cls := "text-sm font-medium text-indigo-600 truncate", + """User Interface Designer""" ), - p( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", - Icons.solid.`location-marker`, - """Remote""" - ) - ), - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - Icons.solid.calendar, - p( - """Closing on""", - time( - datetime := "2020-01-07", - """January 7, 2020""" + div( + cls := "ml-2 flex-shrink-0 flex", + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", + """Full-time""" ) ) - ) - ) - ) - ) - ), - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - """User Interface Designer""" ), div( - cls := "ml-2 flex-shrink-0 flex", - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Full-time""" - ) - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - div( - cls := "sm:flex", - p( - cls := "flex items-center text-sm text-gray-500", - Icons.solid.users, - """Design""" + cls := "mt-2 sm:flex sm:justify-between", + div( + cls := "sm:flex", + p( + cls := "flex items-center text-sm text-gray-500", + Icons.solid.users, + """Design""" + ), + p( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", + Icons.solid.`location-marker`, + """Remote""" + ) ), - p( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", - Icons.solid.`location-marker`, - """Remote""" - ) - ), - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - Icons.solid.calendar, - p( - """Closing on""", - time( - datetime := "2020-01-14", - """January 14, 2020""" + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + Icons.solid.calendar, + p( + """Closing on""", + time( + datetime := "2020-01-14", + """January 14, 2020""" + ) ) ) ) @@ -233,4 +239,3 @@ ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala index 0b2eb22..12d2bc0 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala @@ -3,104 +3,106 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.components.Icons import cz.e_bs.cmi.mdr.pdb.app.Osoba -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo import cz.e_bs.cmi.mdr.pdb.app.Page import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage -def DirectoryPage(data: EventStream[List[Osoba]])(using +case class DirectoryPage(data: EventStream[List[Osoba]])(using router: Router[Page] -): HtmlElement = - div( - cls := "max-w-7xl mx-auto", - //cls := "xl:order-first xl:flex xl:flex-col flex-shrink-0 w-96 border-r border-gray-200", - form( - cls := "p-4 mt-6 flex space-x-4", - action := "#", - div( - cls := "flex-1 min-w-0", - label( - forId := "search", - cls := "sr-only", - """Search""" - ), +) extends AppPage: + + def pageContent: HtmlElement = + div( + cls := "max-w-7xl mx-auto", + //cls := "xl:order-first xl:flex xl:flex-col flex-shrink-0 w-96 border-r border-gray-200", + form( + cls := "p-4 mt-6 flex space-x-4", + action := "#", div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - Icons.solid.search + cls := "flex-1 min-w-0", + label( + forId := "search", + cls := "sr-only", + """Search""" ), - input( - tpe := "search", - name := "search", - idAttr := "search", - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholder := "Search" + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + Icons.solid.search + ), + input( + tpe := "search", + name := "search", + idAttr := "search", + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholder := "Search" + ) + ) + ), + button( + tpe := "submit", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + Icons.solid.filter, + span( + cls := "sr-only", + """Search""" ) ) ), - button( - tpe := "submit", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - Icons.solid.filter, - span( - cls := "sr-only", - """Search""" - ) - ) - ), - nav( - cls := "flex-1 min-h-0 overflow-y-auto", - aria.label := "Directory", - div( - cls := "relative", - // TODO: group by surname + nav( + cls := "flex-1 min-h-0 overflow-y-auto", + aria.label := "Directory", div( - cls := "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500", - h3( - """A""" - ) - ), - ul( - role := "list", - cls := "relative z-0 divide-y divide-gray-200", - // TODO: zero / loading page - children <-- data.map(_.map({ o => - val page = Page.Detail(o.osobniCislo) - li( - div( - cls := "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500", + cls := "relative", + // TODO: group by surname + div( + cls := "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500", + h3( + """A""" + ) + ), + ul( + role := "list", + cls := "relative z-0 divide-y divide-gray-200", + // TODO: zero / loading page + children <-- data.map(_.map({ o => + val page = Page.Detail(o.osobniCislo) + li( div( - cls := "flex-shrink-0", - img( - cls := "h-10 w-10 rounded-full", - src := "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80", - alt := "" - ) - ), - div( - cls := "flex-1 min-w-0", - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "focus:outline-none", - span( - cls := "absolute inset-0", - aria.hidden := true - ), - p( - cls := "text-sm font-medium text-gray-900", - o.jmeno - ), - p( - cls := "text-sm text-gray-500 truncate", - o.hlavniFunkce.nazev + cls := "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500", + div( + cls := "flex-shrink-0", + img( + cls := "h-10 w-10 rounded-full", + src := "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80", + alt := "" + ) + ), + div( + cls := "flex-1 min-w-0", + a( + href := router.absoluteUrlForPage(page), + navigateTo(page), + cls := "focus:outline-none", + span( + cls := "absolute inset-0", + aria.hidden := true + ), + p( + cls := "text-sm font-medium text-gray-900", + o.jmeno + ), + p( + cls := "text-sm text-gray-500 truncate", + o.hlavniFunkce.nazev + ) ) ) ) ) - ) - })) + })) + ) ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala deleted file mode 100644 index 7e8312e..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala +++ /dev/null @@ -1,40 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router - -def PageHeader(using router: 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 - ) - ) - ) - -def MainSection(mods: Modifier[HtmlElement]*): HtmlElement = - main(mods) - -def Layout( - logo: Navigation.Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[Navigation.MenuItem]], - content: HtmlElement -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full", - Navigation( - logo, - profile, - pages, - userMenu - ), - PageHeader, - content - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala deleted file mode 100644 index e518682..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala +++ /dev/null @@ -1,256 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo - -object Navigation: - - case class Logo(name: String, img: String) - - case class MenuItem(title: String) - - given Conversion[Navigation, HtmlElement] = _.render - -import Navigation._ - -case class Navigation( - logo: Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[MenuItem]] -)(using router: Router[Page]): - val mobileMenuOpen = Var(false) - - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - - val desktopOnly = cls("hidden md:block") - val mobileOnly = cls("md:hidden") - - def render: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - 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 - ) - - private inline def avatar(size: Int = 8) = - profile.map(_.userInfo.img match { - case Some(url) => - img( - cls := s"w-$size h-$size rounded-full", - src := url, - alt := "" - ) - case None => - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", - Icons.outline.user(size - 2) - ) - }) - - private def userProfile: HtmlElement = - val menuOpen = 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 - ) - - 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 <-- avatar(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) - ), - /* - * */ - 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 <-- userMenu.map(_.zipWithIndex.map(menuItem)) - ) - ) - - 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 - ) - - div( - cls := "pt-4 pb-3 border-t border-indigo-700", - div( - cls := "flex items-center px-5", - div( - cls := "flex-shrink-0", - child <-- avatar(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- profile.map(_.userInfo.name) - ), - div( - cls := "text-sm font-medium text-indigo-300", - child.text <-- profile.map(_.userInfo.email) - ) - ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - children <-- userMenu.map(_.map(menuItem)) - ) - ) - - private def pageLink(page: Page, active: Signal[Boolean])(using - router: Router[Page] - ): Anchor = - a( - href := router.absoluteUrlForPage(page), - 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 - ) - - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = pages.map( - _.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), - div( - desktopOnly, - div( - cls := "ml-10 flex items-baseline space-x-4", - children <-- pageLinks() - ) - ) - ) - - private def navBarRight = - div( - desktopOnly, - div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile - ) - ) - - 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", - div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile - ) - ) - - def mobileMenu = - div( - 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 - ) 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 new file mode 100644 index 0000000..593d88f --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -0,0 +1,236 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import CustomAttrs.ariaCurrent +import com.raquo.waypoint.Router + +trait NavigationBar[Page](using router: Router[Page]): + self: Navigator[Page] => + + case class Logo(img: String, name: String) + case class MenuItem(title: String) + case class UserInfo(name: String, email: String, img: Option[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 + ) + + private def userProfile: HtmlElement = + val menuOpen = 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 + ) + + 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 + ) + ), + /* + * */ + 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) + ) + ) + + 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 + ) + + 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) + ), + div( + cls := "text-sm font-medium text-indigo-300", + child.text <-- $userInfo.map(_.email) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + userMenu.map(menuItem) + ) + ) + + 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) + ) + + 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), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + pageLinks() + ) + ) + ) + + private def navBarRight = + div( + desktopOnly, + div( + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile + ) + ) + + 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", + 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", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + pageLinks(cls := "block") + ), + mobileProfile + ) 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 new file mode 100644 index 0000000..122dfd2 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -0,0 +1,20 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +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 + +trait PageHeader: + self: Breadcrumbs with Navigator[Page] => + + def pageHeader: 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 + ) + ) + ) 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 new file mode 100644 index 0000000..fcb89aa --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -0,0 +1,17 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} + +trait PageLayout { + def navigation: HtmlElement + def pageHeader: HtmlElement + def pageContent: HtmlElement + + def render: HtmlElement = + div( + cls := "min-h-full", + navigation, + pageHeader, + main(pageContent) + ) +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala new file mode 100644 index 0000000..9e8be6d --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala @@ -0,0 +1,55 @@ +package cz.e_bs.cmi.mdr.pdb.app.components.list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.Icons +import cz.e_bs.cmi.mdr.pdb.app.Routes +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait BaseList[RowData]: + + type RenderRow = Signal[RowData] => Modifier[HtmlElement] + + inline protected def containerElement: HtmlTag[dom.html.Element] = a + + protected val containerMods: RenderRow + protected val title: RenderRow + protected val topRight: RenderRow + protected val bottomLeft: RenderRow + protected val bottomRight: RenderRow + + def row($data: Signal[RowData]) = + li( + containerElement( + containerMods($data), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + title($data) + ), + div( + cls := "ml-2 flex-shrink-0 flex", + topRight($data) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + bottomLeft($data), + bottomRight($data) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala index fdf353c..cc59bee 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala @@ -1,6 +1,10 @@ package cz.e_bs.cmi.mdr.pdb.app.pages import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage +import cz.e_bs.cmi.mdr.pdb.app.Page -def DashboardPage: HtmlElement = - div("Dashboard page") +class DashboardPage(using router: Router[Page]) extends AppPage: + override def pageContent: HtmlElement = + div("Dashboard page") diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala index d299408..077ece7 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala @@ -9,14 +9,16 @@ import cz.e_bs.cmi.mdr.pdb.app.services.DataFetcher import com.raquo.airstream.core.EventStream import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.CustomAttrs.datetime +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage -val datetime = customHtmlAttr("datetime", StringAsIsCodec) - -def DetailPage(fetch: String => EventStream[Osoba])( +case class DetailPage(fetch: String => EventStream[Osoba])( $page: Signal[Page.Detail] -)(using router: Router[Page]): HtmlElement = +)(using + router: Router[Page] +) extends AppPage: // TODO: proper loader - val loading = + private val loading = div( cls := "bg-gray-50 overflow-hidden rounded-lg", div( @@ -24,206 +26,210 @@ "Loading..." ) ) - val data = Var[Option[Osoba]](None) - val $maybeOsoba = data.signal.split(_ => ())((_, _, s) => OsobaView(s)) - val $fetchedData = $page.splitOne(_.osobniCislo)((osc, _, _) => osc) - .flatMap(fetch) - .debugLog() - div( - cls := "max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8", - $fetchedData --> data.writer.contramapSome, - $fetchedData --> (o => router.replaceState(Page.Detail(o))), - child <-- $maybeOsoba.map(_.getOrElse(loading)) - ) -def OsobaView($osoba: Signal[Osoba]): HtmlElement = - def funkce($fce: Signal[Funkce]) = - p( - cls := "text-sm font-medium text-gray-500", - child.text <-- $fce.map(_.nazev), - span( - cls := "hidden md:inline", - " @ ", - child.text <-- $fce.map(_.stredisko), - ", ", - child.text <-- $fce.map(_.voj) - ) - ) - - def pp($pp: Signal[PracovniPomer]) = - p( - cls := "text-sm font-medium text-gray-500", - child.text <-- $pp.map(_.druh), - " od ", - time( - datetime <-- $pp.map(_.pocatek.toString), - child.text <-- $pp.map(_.pocatek.toString) - ) - ) - - div( - cls := "flex flex-col space-y-4", + override def pageContent: HtmlElement = + val data = Var[Option[Osoba]](None) + val $maybeOsoba = + data.signal.split(_ => ())((_, _, s) => osobaView(s)) + val $fetchedData = $page.splitOne(_.osobniCislo)((osc, _, _) => osc) + .flatMap(fetch) + .debugLog() div( - cls := "md:flex md:items-center md:justify-between md:space-x-5", - div( - cls := "flex items-start space-x-5", - div( - cls := "flex-shrink-0", - Avatar($osoba.map(_.img), 16) - ), - div( - h1( - cls := "text-2xl font-bold text-gray-900", - child.text <-- $osoba.map(_.jmeno) - ), - funkce($osoba.map(_.hlavniFunkce)), - pp($osoba.map(_.pracovniPomer)) + cls := "max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8", + $fetchedData --> data.writer.contramapSome, + $fetchedData --> (o => router.replaceState(Page.Detail(o))), + child <-- $maybeOsoba.map(_.getOrElse(loading)) + ) + + private def osobaView($osoba: Signal[Osoba]): HtmlElement = + def funkce($fce: Signal[Funkce]) = + p( + cls := "text-sm font-medium text-gray-500", + child.text <-- $fce.map(_.nazev), + span( + cls := "hidden md:inline", + " @ ", + child.text <-- $fce.map(_.stredisko), + ", ", + child.text <-- $fce.map(_.voj) ) ) - ), + + def pp($pp: Signal[PracovniPomer]) = + p( + cls := "text-sm font-medium text-gray-500", + child.text <-- $pp.map(_.druh), + " od ", + time( + datetime <-- $pp.map(_.pocatek.toString), + child.text <-- $pp.map(_.pocatek.toString) + ) + ) + div( - cls := "bg-white shadow overflow-hidden sm:rounded-md", - ul( - role := "list", - cls := "divide-y divide-gray-200", - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", + cls := "flex flex-col space-y-4", + div( + cls := "md:flex md:items-center md:justify-between md:space-x-5", + div( + cls := "flex items-start space-x-5", + div( + cls := "flex-shrink-0", + Avatar($osoba.map(_.img)).avatar(16) + ), + div( + h1( + cls := "text-2xl font-bold text-gray-900", + child.text <-- $osoba.map(_.jmeno) + ), + funkce($osoba.map(_.hlavniFunkce)), + pp($osoba.map(_.pracovniPomer)) + ) + ) + ), + div( + cls := "bg-white shadow overflow-hidden sm:rounded-md", + ul( + role := "list", + cls := "divide-y divide-gray-200", + li( + a( + href := "#", + cls := "block hover:bg-gray-50", div( - cls := "min-w-0 flex-1 pr-4", + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + "Komise pro pověřování pracovníků" + ), + div( + cls := "ml-2 flex-shrink-0 flex", + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", + """Splněno""" + ) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div(), + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + Icons.solid.calendar, + p( + """do """, + time( + datetime := "2020-01-07", + "01.07.2020" + ) + ) + ) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ), + li( + a( + href := "#", + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6", div( cls := "flex items-center justify-between", p( cls := "text-sm font-medium text-indigo-600 truncate", - "Komise pro pověřování pracovníků" + """Front End Developer""" ), div( cls := "ml-2 flex-shrink-0 flex", p( cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Splněno""" + """Full-time""" ) ) ), div( cls := "mt-2 sm:flex sm:justify-between", - div(), + div( + cls := "sm:flex", + p( + cls := "flex items-center text-sm text-gray-500", + Icons.solid.users, + """Engineering""" + ), + p( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", + Icons.solid.`location-marker`, + """Remote""" + ) + ), div( cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", Icons.solid.calendar, p( - """do """, + """Closing on""", time( datetime := "2020-01-07", - "01.07.2020" + """January 7, 2020""" ) ) ) ) - ), - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right` ) ) - ) - ), - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6", + ), + li( + a( + href := "#", + cls := "block hover:bg-gray-50", div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - """Front End Developer""" - ), + cls := "px-4 py-4 sm:px-6", div( - cls := "ml-2 flex-shrink-0 flex", + cls := "flex items-center justify-between", p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Full-time""" - ) - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - div( - cls := "sm:flex", - p( - cls := "flex items-center text-sm text-gray-500", - Icons.solid.users, - """Engineering""" + cls := "text-sm font-medium text-indigo-600 truncate", + """User Interface Designer""" ), - p( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", - Icons.solid.`location-marker`, - """Remote""" - ) - ), - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - Icons.solid.calendar, - p( - """Closing on""", - time( - datetime := "2020-01-07", - """January 7, 2020""" + div( + cls := "ml-2 flex-shrink-0 flex", + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", + """Full-time""" ) ) - ) - ) - ) - ) - ), - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - """User Interface Designer""" ), div( - cls := "ml-2 flex-shrink-0 flex", - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Full-time""" - ) - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - div( - cls := "sm:flex", - p( - cls := "flex items-center text-sm text-gray-500", - Icons.solid.users, - """Design""" + cls := "mt-2 sm:flex sm:justify-between", + div( + cls := "sm:flex", + p( + cls := "flex items-center text-sm text-gray-500", + Icons.solid.users, + """Design""" + ), + p( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", + Icons.solid.`location-marker`, + """Remote""" + ) ), - p( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", - Icons.solid.`location-marker`, - """Remote""" - ) - ), - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - Icons.solid.calendar, - p( - """Closing on""", - time( - datetime := "2020-01-14", - """January 14, 2020""" + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + Icons.solid.calendar, + p( + """Closing on""", + time( + datetime := "2020-01-14", + """January 14, 2020""" + ) ) ) ) @@ -233,4 +239,3 @@ ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala index 0b2eb22..12d2bc0 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala @@ -3,104 +3,106 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.components.Icons import cz.e_bs.cmi.mdr.pdb.app.Osoba -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo import cz.e_bs.cmi.mdr.pdb.app.Page import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage -def DirectoryPage(data: EventStream[List[Osoba]])(using +case class DirectoryPage(data: EventStream[List[Osoba]])(using router: Router[Page] -): HtmlElement = - div( - cls := "max-w-7xl mx-auto", - //cls := "xl:order-first xl:flex xl:flex-col flex-shrink-0 w-96 border-r border-gray-200", - form( - cls := "p-4 mt-6 flex space-x-4", - action := "#", - div( - cls := "flex-1 min-w-0", - label( - forId := "search", - cls := "sr-only", - """Search""" - ), +) extends AppPage: + + def pageContent: HtmlElement = + div( + cls := "max-w-7xl mx-auto", + //cls := "xl:order-first xl:flex xl:flex-col flex-shrink-0 w-96 border-r border-gray-200", + form( + cls := "p-4 mt-6 flex space-x-4", + action := "#", div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - Icons.solid.search + cls := "flex-1 min-w-0", + label( + forId := "search", + cls := "sr-only", + """Search""" ), - input( - tpe := "search", - name := "search", - idAttr := "search", - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholder := "Search" + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + Icons.solid.search + ), + input( + tpe := "search", + name := "search", + idAttr := "search", + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholder := "Search" + ) + ) + ), + button( + tpe := "submit", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + Icons.solid.filter, + span( + cls := "sr-only", + """Search""" ) ) ), - button( - tpe := "submit", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - Icons.solid.filter, - span( - cls := "sr-only", - """Search""" - ) - ) - ), - nav( - cls := "flex-1 min-h-0 overflow-y-auto", - aria.label := "Directory", - div( - cls := "relative", - // TODO: group by surname + nav( + cls := "flex-1 min-h-0 overflow-y-auto", + aria.label := "Directory", div( - cls := "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500", - h3( - """A""" - ) - ), - ul( - role := "list", - cls := "relative z-0 divide-y divide-gray-200", - // TODO: zero / loading page - children <-- data.map(_.map({ o => - val page = Page.Detail(o.osobniCislo) - li( - div( - cls := "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500", + cls := "relative", + // TODO: group by surname + div( + cls := "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500", + h3( + """A""" + ) + ), + ul( + role := "list", + cls := "relative z-0 divide-y divide-gray-200", + // TODO: zero / loading page + children <-- data.map(_.map({ o => + val page = Page.Detail(o.osobniCislo) + li( div( - cls := "flex-shrink-0", - img( - cls := "h-10 w-10 rounded-full", - src := "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80", - alt := "" - ) - ), - div( - cls := "flex-1 min-w-0", - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "focus:outline-none", - span( - cls := "absolute inset-0", - aria.hidden := true - ), - p( - cls := "text-sm font-medium text-gray-900", - o.jmeno - ), - p( - cls := "text-sm text-gray-500 truncate", - o.hlavniFunkce.nazev + cls := "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500", + div( + cls := "flex-shrink-0", + img( + cls := "h-10 w-10 rounded-full", + src := "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80", + alt := "" + ) + ), + div( + cls := "flex-1 min-w-0", + a( + href := router.absoluteUrlForPage(page), + navigateTo(page), + cls := "focus:outline-none", + span( + cls := "absolute inset-0", + aria.hidden := true + ), + p( + cls := "text-sm font-medium text-gray-900", + o.jmeno + ), + p( + cls := "text-sm text-gray-500 truncate", + o.hlavniFunkce.nazev + ) ) ) ) ) - ) - })) + })) + ) ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala index f0cbc90..aa41c05 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala @@ -4,88 +4,90 @@ import com.raquo.laminar.api.L.{*, given} import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.app.Page -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator -def ErrorPage( +case class ErrorPage( + homePage: Page, errorName: String, title: String, subTitle: String -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full pt-16 pb-12 flex flex-col bg-white", - main( - cls := "flex-grow flex flex-col justify-center max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", - div( - cls := "flex-shrink-0 flex justify-center", - a( - href := "/", - cls := "inline-flex", - span( - cls := "sr-only", - """Workflow""" - ), - img( - cls := "h-12 w-auto", - src := "https://tailwindui.com/img/logos/workflow-mark.svg?color=indigo&shade=600", - alt := "" - ) - ) - ), - div( - cls := "py-16", +)(using router: Router[Page]) + extends Navigator[Page]: + def render: HtmlElement = + div( + cls := "min-h-full pt-16 pb-12 flex flex-col bg-white", + main( + cls := "flex-grow flex flex-col justify-center max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", div( - cls := "text-center", - p( - cls := "text-sm font-semibold text-indigo-600 uppercase tracking-wide", - errorName - ), - h1( - cls := "mt-2 text-4xl font-extrabold text-gray-900 tracking-tight sm:text-5xl", - title - ), - p( - cls := "mt-2 text-base text-gray-500", - subTitle - ), + cls := "flex-shrink-0 flex justify-center", + a( + href := "/", + cls := "inline-flex", + span( + cls := "sr-only", + """Workflow""" + ), + img( + cls := "h-12 w-auto", + src := "https://tailwindui.com/img/logos/workflow-mark.svg?color=indigo&shade=600", + alt := "" + ) + ) + ), + div( + cls := "py-16", div( - cls := "mt-6", - a( - href := router.absoluteUrlForPage(Page.Dashboard), - navigateTo(Page.Dashboard), - cls := "text-base font-medium text-indigo-600 hover:text-indigo-500", - """Go back home""" + cls := "text-center", + p( + cls := "text-sm font-semibold text-indigo-600 uppercase tracking-wide", + errorName + ), + h1( + cls := "mt-2 text-4xl font-extrabold text-gray-900 tracking-tight sm:text-5xl", + title + ), + p( + cls := "mt-2 text-base text-gray-500", + subTitle + ), + div( + cls := "mt-6", + a( + navigateTo(homePage), + cls := "text-base font-medium text-indigo-600 hover:text-indigo-500", + """Go back home""" + ) ) ) ) - ) - ), - footer( - cls := "flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", - nav( - cls := "flex justify-center space-x-4", - a( - href := "#", - cls := "text-sm font-medium text-gray-500 hover:text-gray-600", - """Contact Support""" - ), - span( - cls := "inline-block border-l border-gray-300", - aria.hidden := true - ), - a( - href := "#", - cls := "text-sm font-medium text-gray-500 hover:text-gray-600", - """Status""" - ), - span( - cls := "inline-block border-l border-gray-300", - aria.hidden := true - ), - a( - href := "#", - cls := "text-sm font-medium text-gray-500 hover:text-gray-600", - """Twitter""" + ), + footer( + cls := "flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", + nav( + cls := "flex justify-center space-x-4", + a( + href := "#", + cls := "text-sm font-medium text-gray-500 hover:text-gray-600", + """Contact Support""" + ), + span( + cls := "inline-block border-l border-gray-300", + aria.hidden := true + ), + a( + href := "#", + cls := "text-sm font-medium text-gray-500 hover:text-gray-600", + """Status""" + ), + span( + cls := "inline-block border-l border-gray-300", + aria.hidden := true + ), + a( + href := "#", + cls := "text-sm font-medium text-gray-500 hover:text-gray-600", + """Twitter""" + ) ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala deleted file mode 100644 index 7e8312e..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala +++ /dev/null @@ -1,40 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router - -def PageHeader(using router: 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 - ) - ) - ) - -def MainSection(mods: Modifier[HtmlElement]*): HtmlElement = - main(mods) - -def Layout( - logo: Navigation.Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[Navigation.MenuItem]], - content: HtmlElement -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full", - Navigation( - logo, - profile, - pages, - userMenu - ), - PageHeader, - content - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala deleted file mode 100644 index e518682..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala +++ /dev/null @@ -1,256 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo - -object Navigation: - - case class Logo(name: String, img: String) - - case class MenuItem(title: String) - - given Conversion[Navigation, HtmlElement] = _.render - -import Navigation._ - -case class Navigation( - logo: Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[MenuItem]] -)(using router: Router[Page]): - val mobileMenuOpen = Var(false) - - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - - val desktopOnly = cls("hidden md:block") - val mobileOnly = cls("md:hidden") - - def render: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - 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 - ) - - private inline def avatar(size: Int = 8) = - profile.map(_.userInfo.img match { - case Some(url) => - img( - cls := s"w-$size h-$size rounded-full", - src := url, - alt := "" - ) - case None => - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", - Icons.outline.user(size - 2) - ) - }) - - private def userProfile: HtmlElement = - val menuOpen = 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 - ) - - 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 <-- avatar(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) - ), - /* - * */ - 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 <-- userMenu.map(_.zipWithIndex.map(menuItem)) - ) - ) - - 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 - ) - - div( - cls := "pt-4 pb-3 border-t border-indigo-700", - div( - cls := "flex items-center px-5", - div( - cls := "flex-shrink-0", - child <-- avatar(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- profile.map(_.userInfo.name) - ), - div( - cls := "text-sm font-medium text-indigo-300", - child.text <-- profile.map(_.userInfo.email) - ) - ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - children <-- userMenu.map(_.map(menuItem)) - ) - ) - - private def pageLink(page: Page, active: Signal[Boolean])(using - router: Router[Page] - ): Anchor = - a( - href := router.absoluteUrlForPage(page), - 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 - ) - - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = pages.map( - _.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), - div( - desktopOnly, - div( - cls := "ml-10 flex items-baseline space-x-4", - children <-- pageLinks() - ) - ) - ) - - private def navBarRight = - div( - desktopOnly, - div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile - ) - ) - - 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", - div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile - ) - ) - - def mobileMenu = - div( - 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 - ) 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 new file mode 100644 index 0000000..593d88f --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -0,0 +1,236 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import CustomAttrs.ariaCurrent +import com.raquo.waypoint.Router + +trait NavigationBar[Page](using router: Router[Page]): + self: Navigator[Page] => + + case class Logo(img: String, name: String) + case class MenuItem(title: String) + case class UserInfo(name: String, email: String, img: Option[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 + ) + + private def userProfile: HtmlElement = + val menuOpen = 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 + ) + + 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 + ) + ), + /* + * */ + 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) + ) + ) + + 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 + ) + + 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) + ), + div( + cls := "text-sm font-medium text-indigo-300", + child.text <-- $userInfo.map(_.email) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + userMenu.map(menuItem) + ) + ) + + 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) + ) + + 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), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + pageLinks() + ) + ) + ) + + private def navBarRight = + div( + desktopOnly, + div( + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile + ) + ) + + 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", + 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", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + pageLinks(cls := "block") + ), + mobileProfile + ) 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 new file mode 100644 index 0000000..122dfd2 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -0,0 +1,20 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +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 + +trait PageHeader: + self: Breadcrumbs with Navigator[Page] => + + def pageHeader: 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 + ) + ) + ) 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 new file mode 100644 index 0000000..fcb89aa --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -0,0 +1,17 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} + +trait PageLayout { + def navigation: HtmlElement + def pageHeader: HtmlElement + def pageContent: HtmlElement + + def render: HtmlElement = + div( + cls := "min-h-full", + navigation, + pageHeader, + main(pageContent) + ) +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala new file mode 100644 index 0000000..9e8be6d --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala @@ -0,0 +1,55 @@ +package cz.e_bs.cmi.mdr.pdb.app.components.list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.Icons +import cz.e_bs.cmi.mdr.pdb.app.Routes +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait BaseList[RowData]: + + type RenderRow = Signal[RowData] => Modifier[HtmlElement] + + inline protected def containerElement: HtmlTag[dom.html.Element] = a + + protected val containerMods: RenderRow + protected val title: RenderRow + protected val topRight: RenderRow + protected val bottomLeft: RenderRow + protected val bottomRight: RenderRow + + def row($data: Signal[RowData]) = + li( + containerElement( + containerMods($data), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + title($data) + ), + div( + cls := "ml-2 flex-shrink-0 flex", + topRight($data) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + bottomLeft($data), + bottomRight($data) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala index fdf353c..cc59bee 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala @@ -1,6 +1,10 @@ package cz.e_bs.cmi.mdr.pdb.app.pages import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage +import cz.e_bs.cmi.mdr.pdb.app.Page -def DashboardPage: HtmlElement = - div("Dashboard page") +class DashboardPage(using router: Router[Page]) extends AppPage: + override def pageContent: HtmlElement = + div("Dashboard page") diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala index d299408..077ece7 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala @@ -9,14 +9,16 @@ import cz.e_bs.cmi.mdr.pdb.app.services.DataFetcher import com.raquo.airstream.core.EventStream import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.CustomAttrs.datetime +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage -val datetime = customHtmlAttr("datetime", StringAsIsCodec) - -def DetailPage(fetch: String => EventStream[Osoba])( +case class DetailPage(fetch: String => EventStream[Osoba])( $page: Signal[Page.Detail] -)(using router: Router[Page]): HtmlElement = +)(using + router: Router[Page] +) extends AppPage: // TODO: proper loader - val loading = + private val loading = div( cls := "bg-gray-50 overflow-hidden rounded-lg", div( @@ -24,206 +26,210 @@ "Loading..." ) ) - val data = Var[Option[Osoba]](None) - val $maybeOsoba = data.signal.split(_ => ())((_, _, s) => OsobaView(s)) - val $fetchedData = $page.splitOne(_.osobniCislo)((osc, _, _) => osc) - .flatMap(fetch) - .debugLog() - div( - cls := "max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8", - $fetchedData --> data.writer.contramapSome, - $fetchedData --> (o => router.replaceState(Page.Detail(o))), - child <-- $maybeOsoba.map(_.getOrElse(loading)) - ) -def OsobaView($osoba: Signal[Osoba]): HtmlElement = - def funkce($fce: Signal[Funkce]) = - p( - cls := "text-sm font-medium text-gray-500", - child.text <-- $fce.map(_.nazev), - span( - cls := "hidden md:inline", - " @ ", - child.text <-- $fce.map(_.stredisko), - ", ", - child.text <-- $fce.map(_.voj) - ) - ) - - def pp($pp: Signal[PracovniPomer]) = - p( - cls := "text-sm font-medium text-gray-500", - child.text <-- $pp.map(_.druh), - " od ", - time( - datetime <-- $pp.map(_.pocatek.toString), - child.text <-- $pp.map(_.pocatek.toString) - ) - ) - - div( - cls := "flex flex-col space-y-4", + override def pageContent: HtmlElement = + val data = Var[Option[Osoba]](None) + val $maybeOsoba = + data.signal.split(_ => ())((_, _, s) => osobaView(s)) + val $fetchedData = $page.splitOne(_.osobniCislo)((osc, _, _) => osc) + .flatMap(fetch) + .debugLog() div( - cls := "md:flex md:items-center md:justify-between md:space-x-5", - div( - cls := "flex items-start space-x-5", - div( - cls := "flex-shrink-0", - Avatar($osoba.map(_.img), 16) - ), - div( - h1( - cls := "text-2xl font-bold text-gray-900", - child.text <-- $osoba.map(_.jmeno) - ), - funkce($osoba.map(_.hlavniFunkce)), - pp($osoba.map(_.pracovniPomer)) + cls := "max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8", + $fetchedData --> data.writer.contramapSome, + $fetchedData --> (o => router.replaceState(Page.Detail(o))), + child <-- $maybeOsoba.map(_.getOrElse(loading)) + ) + + private def osobaView($osoba: Signal[Osoba]): HtmlElement = + def funkce($fce: Signal[Funkce]) = + p( + cls := "text-sm font-medium text-gray-500", + child.text <-- $fce.map(_.nazev), + span( + cls := "hidden md:inline", + " @ ", + child.text <-- $fce.map(_.stredisko), + ", ", + child.text <-- $fce.map(_.voj) ) ) - ), + + def pp($pp: Signal[PracovniPomer]) = + p( + cls := "text-sm font-medium text-gray-500", + child.text <-- $pp.map(_.druh), + " od ", + time( + datetime <-- $pp.map(_.pocatek.toString), + child.text <-- $pp.map(_.pocatek.toString) + ) + ) + div( - cls := "bg-white shadow overflow-hidden sm:rounded-md", - ul( - role := "list", - cls := "divide-y divide-gray-200", - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", + cls := "flex flex-col space-y-4", + div( + cls := "md:flex md:items-center md:justify-between md:space-x-5", + div( + cls := "flex items-start space-x-5", + div( + cls := "flex-shrink-0", + Avatar($osoba.map(_.img)).avatar(16) + ), + div( + h1( + cls := "text-2xl font-bold text-gray-900", + child.text <-- $osoba.map(_.jmeno) + ), + funkce($osoba.map(_.hlavniFunkce)), + pp($osoba.map(_.pracovniPomer)) + ) + ) + ), + div( + cls := "bg-white shadow overflow-hidden sm:rounded-md", + ul( + role := "list", + cls := "divide-y divide-gray-200", + li( + a( + href := "#", + cls := "block hover:bg-gray-50", div( - cls := "min-w-0 flex-1 pr-4", + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + "Komise pro pověřování pracovníků" + ), + div( + cls := "ml-2 flex-shrink-0 flex", + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", + """Splněno""" + ) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div(), + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + Icons.solid.calendar, + p( + """do """, + time( + datetime := "2020-01-07", + "01.07.2020" + ) + ) + ) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ), + li( + a( + href := "#", + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6", div( cls := "flex items-center justify-between", p( cls := "text-sm font-medium text-indigo-600 truncate", - "Komise pro pověřování pracovníků" + """Front End Developer""" ), div( cls := "ml-2 flex-shrink-0 flex", p( cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Splněno""" + """Full-time""" ) ) ), div( cls := "mt-2 sm:flex sm:justify-between", - div(), + div( + cls := "sm:flex", + p( + cls := "flex items-center text-sm text-gray-500", + Icons.solid.users, + """Engineering""" + ), + p( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", + Icons.solid.`location-marker`, + """Remote""" + ) + ), div( cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", Icons.solid.calendar, p( - """do """, + """Closing on""", time( datetime := "2020-01-07", - "01.07.2020" + """January 7, 2020""" ) ) ) ) - ), - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right` ) ) - ) - ), - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6", + ), + li( + a( + href := "#", + cls := "block hover:bg-gray-50", div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - """Front End Developer""" - ), + cls := "px-4 py-4 sm:px-6", div( - cls := "ml-2 flex-shrink-0 flex", + cls := "flex items-center justify-between", p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Full-time""" - ) - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - div( - cls := "sm:flex", - p( - cls := "flex items-center text-sm text-gray-500", - Icons.solid.users, - """Engineering""" + cls := "text-sm font-medium text-indigo-600 truncate", + """User Interface Designer""" ), - p( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", - Icons.solid.`location-marker`, - """Remote""" - ) - ), - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - Icons.solid.calendar, - p( - """Closing on""", - time( - datetime := "2020-01-07", - """January 7, 2020""" + div( + cls := "ml-2 flex-shrink-0 flex", + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", + """Full-time""" ) ) - ) - ) - ) - ) - ), - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - """User Interface Designer""" ), div( - cls := "ml-2 flex-shrink-0 flex", - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Full-time""" - ) - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - div( - cls := "sm:flex", - p( - cls := "flex items-center text-sm text-gray-500", - Icons.solid.users, - """Design""" + cls := "mt-2 sm:flex sm:justify-between", + div( + cls := "sm:flex", + p( + cls := "flex items-center text-sm text-gray-500", + Icons.solid.users, + """Design""" + ), + p( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", + Icons.solid.`location-marker`, + """Remote""" + ) ), - p( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", - Icons.solid.`location-marker`, - """Remote""" - ) - ), - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - Icons.solid.calendar, - p( - """Closing on""", - time( - datetime := "2020-01-14", - """January 14, 2020""" + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + Icons.solid.calendar, + p( + """Closing on""", + time( + datetime := "2020-01-14", + """January 14, 2020""" + ) ) ) ) @@ -233,4 +239,3 @@ ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala index 0b2eb22..12d2bc0 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala @@ -3,104 +3,106 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.components.Icons import cz.e_bs.cmi.mdr.pdb.app.Osoba -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo import cz.e_bs.cmi.mdr.pdb.app.Page import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage -def DirectoryPage(data: EventStream[List[Osoba]])(using +case class DirectoryPage(data: EventStream[List[Osoba]])(using router: Router[Page] -): HtmlElement = - div( - cls := "max-w-7xl mx-auto", - //cls := "xl:order-first xl:flex xl:flex-col flex-shrink-0 w-96 border-r border-gray-200", - form( - cls := "p-4 mt-6 flex space-x-4", - action := "#", - div( - cls := "flex-1 min-w-0", - label( - forId := "search", - cls := "sr-only", - """Search""" - ), +) extends AppPage: + + def pageContent: HtmlElement = + div( + cls := "max-w-7xl mx-auto", + //cls := "xl:order-first xl:flex xl:flex-col flex-shrink-0 w-96 border-r border-gray-200", + form( + cls := "p-4 mt-6 flex space-x-4", + action := "#", div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - Icons.solid.search + cls := "flex-1 min-w-0", + label( + forId := "search", + cls := "sr-only", + """Search""" ), - input( - tpe := "search", - name := "search", - idAttr := "search", - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholder := "Search" + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + Icons.solid.search + ), + input( + tpe := "search", + name := "search", + idAttr := "search", + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholder := "Search" + ) + ) + ), + button( + tpe := "submit", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + Icons.solid.filter, + span( + cls := "sr-only", + """Search""" ) ) ), - button( - tpe := "submit", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - Icons.solid.filter, - span( - cls := "sr-only", - """Search""" - ) - ) - ), - nav( - cls := "flex-1 min-h-0 overflow-y-auto", - aria.label := "Directory", - div( - cls := "relative", - // TODO: group by surname + nav( + cls := "flex-1 min-h-0 overflow-y-auto", + aria.label := "Directory", div( - cls := "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500", - h3( - """A""" - ) - ), - ul( - role := "list", - cls := "relative z-0 divide-y divide-gray-200", - // TODO: zero / loading page - children <-- data.map(_.map({ o => - val page = Page.Detail(o.osobniCislo) - li( - div( - cls := "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500", + cls := "relative", + // TODO: group by surname + div( + cls := "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500", + h3( + """A""" + ) + ), + ul( + role := "list", + cls := "relative z-0 divide-y divide-gray-200", + // TODO: zero / loading page + children <-- data.map(_.map({ o => + val page = Page.Detail(o.osobniCislo) + li( div( - cls := "flex-shrink-0", - img( - cls := "h-10 w-10 rounded-full", - src := "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80", - alt := "" - ) - ), - div( - cls := "flex-1 min-w-0", - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "focus:outline-none", - span( - cls := "absolute inset-0", - aria.hidden := true - ), - p( - cls := "text-sm font-medium text-gray-900", - o.jmeno - ), - p( - cls := "text-sm text-gray-500 truncate", - o.hlavniFunkce.nazev + cls := "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500", + div( + cls := "flex-shrink-0", + img( + cls := "h-10 w-10 rounded-full", + src := "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80", + alt := "" + ) + ), + div( + cls := "flex-1 min-w-0", + a( + href := router.absoluteUrlForPage(page), + navigateTo(page), + cls := "focus:outline-none", + span( + cls := "absolute inset-0", + aria.hidden := true + ), + p( + cls := "text-sm font-medium text-gray-900", + o.jmeno + ), + p( + cls := "text-sm text-gray-500 truncate", + o.hlavniFunkce.nazev + ) ) ) ) ) - ) - })) + })) + ) ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala index f0cbc90..aa41c05 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala @@ -4,88 +4,90 @@ import com.raquo.laminar.api.L.{*, given} import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.app.Page -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator -def ErrorPage( +case class ErrorPage( + homePage: Page, errorName: String, title: String, subTitle: String -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full pt-16 pb-12 flex flex-col bg-white", - main( - cls := "flex-grow flex flex-col justify-center max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", - div( - cls := "flex-shrink-0 flex justify-center", - a( - href := "/", - cls := "inline-flex", - span( - cls := "sr-only", - """Workflow""" - ), - img( - cls := "h-12 w-auto", - src := "https://tailwindui.com/img/logos/workflow-mark.svg?color=indigo&shade=600", - alt := "" - ) - ) - ), - div( - cls := "py-16", +)(using router: Router[Page]) + extends Navigator[Page]: + def render: HtmlElement = + div( + cls := "min-h-full pt-16 pb-12 flex flex-col bg-white", + main( + cls := "flex-grow flex flex-col justify-center max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", div( - cls := "text-center", - p( - cls := "text-sm font-semibold text-indigo-600 uppercase tracking-wide", - errorName - ), - h1( - cls := "mt-2 text-4xl font-extrabold text-gray-900 tracking-tight sm:text-5xl", - title - ), - p( - cls := "mt-2 text-base text-gray-500", - subTitle - ), + cls := "flex-shrink-0 flex justify-center", + a( + href := "/", + cls := "inline-flex", + span( + cls := "sr-only", + """Workflow""" + ), + img( + cls := "h-12 w-auto", + src := "https://tailwindui.com/img/logos/workflow-mark.svg?color=indigo&shade=600", + alt := "" + ) + ) + ), + div( + cls := "py-16", div( - cls := "mt-6", - a( - href := router.absoluteUrlForPage(Page.Dashboard), - navigateTo(Page.Dashboard), - cls := "text-base font-medium text-indigo-600 hover:text-indigo-500", - """Go back home""" + cls := "text-center", + p( + cls := "text-sm font-semibold text-indigo-600 uppercase tracking-wide", + errorName + ), + h1( + cls := "mt-2 text-4xl font-extrabold text-gray-900 tracking-tight sm:text-5xl", + title + ), + p( + cls := "mt-2 text-base text-gray-500", + subTitle + ), + div( + cls := "mt-6", + a( + navigateTo(homePage), + cls := "text-base font-medium text-indigo-600 hover:text-indigo-500", + """Go back home""" + ) ) ) ) - ) - ), - footer( - cls := "flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", - nav( - cls := "flex justify-center space-x-4", - a( - href := "#", - cls := "text-sm font-medium text-gray-500 hover:text-gray-600", - """Contact Support""" - ), - span( - cls := "inline-block border-l border-gray-300", - aria.hidden := true - ), - a( - href := "#", - cls := "text-sm font-medium text-gray-500 hover:text-gray-600", - """Status""" - ), - span( - cls := "inline-block border-l border-gray-300", - aria.hidden := true - ), - a( - href := "#", - cls := "text-sm font-medium text-gray-500 hover:text-gray-600", - """Twitter""" + ), + footer( + cls := "flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", + nav( + cls := "flex justify-center space-x-4", + a( + href := "#", + cls := "text-sm font-medium text-gray-500 hover:text-gray-600", + """Contact Support""" + ), + span( + cls := "inline-block border-l border-gray-300", + aria.hidden := true + ), + a( + href := "#", + cls := "text-sm font-medium text-gray-500 hover:text-gray-600", + """Status""" + ), + span( + cls := "inline-block border-l border-gray-300", + aria.hidden := true + ), + a( + href := "#", + cls := "text-sm font-medium text-gray-500 hover:text-gray-600", + """Twitter""" + ) ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/NotFoundPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/NotFoundPage.scala index 901518f..787a250 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/NotFoundPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/NotFoundPage.scala @@ -5,11 +5,12 @@ import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.app.Page -def NotFoundPage(url: String)(using +def NotFoundPage(homePage: Page, url: String)(using router: Router[Page] ): HtmlElement = ErrorPage( + homePage, "404 error", "Page not found.", s"Sorry, but page $url doesn't exist." - ) + ).render diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala deleted file mode 100644 index 7e8312e..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala +++ /dev/null @@ -1,40 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router - -def PageHeader(using router: 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 - ) - ) - ) - -def MainSection(mods: Modifier[HtmlElement]*): HtmlElement = - main(mods) - -def Layout( - logo: Navigation.Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[Navigation.MenuItem]], - content: HtmlElement -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full", - Navigation( - logo, - profile, - pages, - userMenu - ), - PageHeader, - content - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala deleted file mode 100644 index e518682..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala +++ /dev/null @@ -1,256 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo - -object Navigation: - - case class Logo(name: String, img: String) - - case class MenuItem(title: String) - - given Conversion[Navigation, HtmlElement] = _.render - -import Navigation._ - -case class Navigation( - logo: Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[MenuItem]] -)(using router: Router[Page]): - val mobileMenuOpen = Var(false) - - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - - val desktopOnly = cls("hidden md:block") - val mobileOnly = cls("md:hidden") - - def render: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - 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 - ) - - private inline def avatar(size: Int = 8) = - profile.map(_.userInfo.img match { - case Some(url) => - img( - cls := s"w-$size h-$size rounded-full", - src := url, - alt := "" - ) - case None => - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", - Icons.outline.user(size - 2) - ) - }) - - private def userProfile: HtmlElement = - val menuOpen = 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 - ) - - 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 <-- avatar(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) - ), - /* - * */ - 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 <-- userMenu.map(_.zipWithIndex.map(menuItem)) - ) - ) - - 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 - ) - - div( - cls := "pt-4 pb-3 border-t border-indigo-700", - div( - cls := "flex items-center px-5", - div( - cls := "flex-shrink-0", - child <-- avatar(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- profile.map(_.userInfo.name) - ), - div( - cls := "text-sm font-medium text-indigo-300", - child.text <-- profile.map(_.userInfo.email) - ) - ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - children <-- userMenu.map(_.map(menuItem)) - ) - ) - - private def pageLink(page: Page, active: Signal[Boolean])(using - router: Router[Page] - ): Anchor = - a( - href := router.absoluteUrlForPage(page), - 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 - ) - - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = pages.map( - _.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), - div( - desktopOnly, - div( - cls := "ml-10 flex items-baseline space-x-4", - children <-- pageLinks() - ) - ) - ) - - private def navBarRight = - div( - desktopOnly, - div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile - ) - ) - - 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", - div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile - ) - ) - - def mobileMenu = - div( - 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 - ) 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 new file mode 100644 index 0000000..593d88f --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -0,0 +1,236 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import CustomAttrs.ariaCurrent +import com.raquo.waypoint.Router + +trait NavigationBar[Page](using router: Router[Page]): + self: Navigator[Page] => + + case class Logo(img: String, name: String) + case class MenuItem(title: String) + case class UserInfo(name: String, email: String, img: Option[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 + ) + + private def userProfile: HtmlElement = + val menuOpen = 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 + ) + + 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 + ) + ), + /* + * */ + 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) + ) + ) + + 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 + ) + + 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) + ), + div( + cls := "text-sm font-medium text-indigo-300", + child.text <-- $userInfo.map(_.email) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + userMenu.map(menuItem) + ) + ) + + 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) + ) + + 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), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + pageLinks() + ) + ) + ) + + private def navBarRight = + div( + desktopOnly, + div( + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile + ) + ) + + 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", + 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", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + pageLinks(cls := "block") + ), + mobileProfile + ) 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 new file mode 100644 index 0000000..122dfd2 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -0,0 +1,20 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +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 + +trait PageHeader: + self: Breadcrumbs with Navigator[Page] => + + def pageHeader: 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 + ) + ) + ) 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 new file mode 100644 index 0000000..fcb89aa --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -0,0 +1,17 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} + +trait PageLayout { + def navigation: HtmlElement + def pageHeader: HtmlElement + def pageContent: HtmlElement + + def render: HtmlElement = + div( + cls := "min-h-full", + navigation, + pageHeader, + main(pageContent) + ) +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala new file mode 100644 index 0000000..9e8be6d --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala @@ -0,0 +1,55 @@ +package cz.e_bs.cmi.mdr.pdb.app.components.list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.Icons +import cz.e_bs.cmi.mdr.pdb.app.Routes +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait BaseList[RowData]: + + type RenderRow = Signal[RowData] => Modifier[HtmlElement] + + inline protected def containerElement: HtmlTag[dom.html.Element] = a + + protected val containerMods: RenderRow + protected val title: RenderRow + protected val topRight: RenderRow + protected val bottomLeft: RenderRow + protected val bottomRight: RenderRow + + def row($data: Signal[RowData]) = + li( + containerElement( + containerMods($data), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + title($data) + ), + div( + cls := "ml-2 flex-shrink-0 flex", + topRight($data) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + bottomLeft($data), + bottomRight($data) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala index fdf353c..cc59bee 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala @@ -1,6 +1,10 @@ package cz.e_bs.cmi.mdr.pdb.app.pages import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage +import cz.e_bs.cmi.mdr.pdb.app.Page -def DashboardPage: HtmlElement = - div("Dashboard page") +class DashboardPage(using router: Router[Page]) extends AppPage: + override def pageContent: HtmlElement = + div("Dashboard page") diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala index d299408..077ece7 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala @@ -9,14 +9,16 @@ import cz.e_bs.cmi.mdr.pdb.app.services.DataFetcher import com.raquo.airstream.core.EventStream import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.CustomAttrs.datetime +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage -val datetime = customHtmlAttr("datetime", StringAsIsCodec) - -def DetailPage(fetch: String => EventStream[Osoba])( +case class DetailPage(fetch: String => EventStream[Osoba])( $page: Signal[Page.Detail] -)(using router: Router[Page]): HtmlElement = +)(using + router: Router[Page] +) extends AppPage: // TODO: proper loader - val loading = + private val loading = div( cls := "bg-gray-50 overflow-hidden rounded-lg", div( @@ -24,206 +26,210 @@ "Loading..." ) ) - val data = Var[Option[Osoba]](None) - val $maybeOsoba = data.signal.split(_ => ())((_, _, s) => OsobaView(s)) - val $fetchedData = $page.splitOne(_.osobniCislo)((osc, _, _) => osc) - .flatMap(fetch) - .debugLog() - div( - cls := "max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8", - $fetchedData --> data.writer.contramapSome, - $fetchedData --> (o => router.replaceState(Page.Detail(o))), - child <-- $maybeOsoba.map(_.getOrElse(loading)) - ) -def OsobaView($osoba: Signal[Osoba]): HtmlElement = - def funkce($fce: Signal[Funkce]) = - p( - cls := "text-sm font-medium text-gray-500", - child.text <-- $fce.map(_.nazev), - span( - cls := "hidden md:inline", - " @ ", - child.text <-- $fce.map(_.stredisko), - ", ", - child.text <-- $fce.map(_.voj) - ) - ) - - def pp($pp: Signal[PracovniPomer]) = - p( - cls := "text-sm font-medium text-gray-500", - child.text <-- $pp.map(_.druh), - " od ", - time( - datetime <-- $pp.map(_.pocatek.toString), - child.text <-- $pp.map(_.pocatek.toString) - ) - ) - - div( - cls := "flex flex-col space-y-4", + override def pageContent: HtmlElement = + val data = Var[Option[Osoba]](None) + val $maybeOsoba = + data.signal.split(_ => ())((_, _, s) => osobaView(s)) + val $fetchedData = $page.splitOne(_.osobniCislo)((osc, _, _) => osc) + .flatMap(fetch) + .debugLog() div( - cls := "md:flex md:items-center md:justify-between md:space-x-5", - div( - cls := "flex items-start space-x-5", - div( - cls := "flex-shrink-0", - Avatar($osoba.map(_.img), 16) - ), - div( - h1( - cls := "text-2xl font-bold text-gray-900", - child.text <-- $osoba.map(_.jmeno) - ), - funkce($osoba.map(_.hlavniFunkce)), - pp($osoba.map(_.pracovniPomer)) + cls := "max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8", + $fetchedData --> data.writer.contramapSome, + $fetchedData --> (o => router.replaceState(Page.Detail(o))), + child <-- $maybeOsoba.map(_.getOrElse(loading)) + ) + + private def osobaView($osoba: Signal[Osoba]): HtmlElement = + def funkce($fce: Signal[Funkce]) = + p( + cls := "text-sm font-medium text-gray-500", + child.text <-- $fce.map(_.nazev), + span( + cls := "hidden md:inline", + " @ ", + child.text <-- $fce.map(_.stredisko), + ", ", + child.text <-- $fce.map(_.voj) ) ) - ), + + def pp($pp: Signal[PracovniPomer]) = + p( + cls := "text-sm font-medium text-gray-500", + child.text <-- $pp.map(_.druh), + " od ", + time( + datetime <-- $pp.map(_.pocatek.toString), + child.text <-- $pp.map(_.pocatek.toString) + ) + ) + div( - cls := "bg-white shadow overflow-hidden sm:rounded-md", - ul( - role := "list", - cls := "divide-y divide-gray-200", - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", + cls := "flex flex-col space-y-4", + div( + cls := "md:flex md:items-center md:justify-between md:space-x-5", + div( + cls := "flex items-start space-x-5", + div( + cls := "flex-shrink-0", + Avatar($osoba.map(_.img)).avatar(16) + ), + div( + h1( + cls := "text-2xl font-bold text-gray-900", + child.text <-- $osoba.map(_.jmeno) + ), + funkce($osoba.map(_.hlavniFunkce)), + pp($osoba.map(_.pracovniPomer)) + ) + ) + ), + div( + cls := "bg-white shadow overflow-hidden sm:rounded-md", + ul( + role := "list", + cls := "divide-y divide-gray-200", + li( + a( + href := "#", + cls := "block hover:bg-gray-50", div( - cls := "min-w-0 flex-1 pr-4", + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + "Komise pro pověřování pracovníků" + ), + div( + cls := "ml-2 flex-shrink-0 flex", + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", + """Splněno""" + ) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div(), + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + Icons.solid.calendar, + p( + """do """, + time( + datetime := "2020-01-07", + "01.07.2020" + ) + ) + ) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ), + li( + a( + href := "#", + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6", div( cls := "flex items-center justify-between", p( cls := "text-sm font-medium text-indigo-600 truncate", - "Komise pro pověřování pracovníků" + """Front End Developer""" ), div( cls := "ml-2 flex-shrink-0 flex", p( cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Splněno""" + """Full-time""" ) ) ), div( cls := "mt-2 sm:flex sm:justify-between", - div(), + div( + cls := "sm:flex", + p( + cls := "flex items-center text-sm text-gray-500", + Icons.solid.users, + """Engineering""" + ), + p( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", + Icons.solid.`location-marker`, + """Remote""" + ) + ), div( cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", Icons.solid.calendar, p( - """do """, + """Closing on""", time( datetime := "2020-01-07", - "01.07.2020" + """January 7, 2020""" ) ) ) ) - ), - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right` ) ) - ) - ), - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6", + ), + li( + a( + href := "#", + cls := "block hover:bg-gray-50", div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - """Front End Developer""" - ), + cls := "px-4 py-4 sm:px-6", div( - cls := "ml-2 flex-shrink-0 flex", + cls := "flex items-center justify-between", p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Full-time""" - ) - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - div( - cls := "sm:flex", - p( - cls := "flex items-center text-sm text-gray-500", - Icons.solid.users, - """Engineering""" + cls := "text-sm font-medium text-indigo-600 truncate", + """User Interface Designer""" ), - p( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", - Icons.solid.`location-marker`, - """Remote""" - ) - ), - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - Icons.solid.calendar, - p( - """Closing on""", - time( - datetime := "2020-01-07", - """January 7, 2020""" + div( + cls := "ml-2 flex-shrink-0 flex", + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", + """Full-time""" ) ) - ) - ) - ) - ) - ), - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - """User Interface Designer""" ), div( - cls := "ml-2 flex-shrink-0 flex", - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Full-time""" - ) - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - div( - cls := "sm:flex", - p( - cls := "flex items-center text-sm text-gray-500", - Icons.solid.users, - """Design""" + cls := "mt-2 sm:flex sm:justify-between", + div( + cls := "sm:flex", + p( + cls := "flex items-center text-sm text-gray-500", + Icons.solid.users, + """Design""" + ), + p( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", + Icons.solid.`location-marker`, + """Remote""" + ) ), - p( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", - Icons.solid.`location-marker`, - """Remote""" - ) - ), - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - Icons.solid.calendar, - p( - """Closing on""", - time( - datetime := "2020-01-14", - """January 14, 2020""" + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + Icons.solid.calendar, + p( + """Closing on""", + time( + datetime := "2020-01-14", + """January 14, 2020""" + ) ) ) ) @@ -233,4 +239,3 @@ ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala index 0b2eb22..12d2bc0 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala @@ -3,104 +3,106 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.components.Icons import cz.e_bs.cmi.mdr.pdb.app.Osoba -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo import cz.e_bs.cmi.mdr.pdb.app.Page import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage -def DirectoryPage(data: EventStream[List[Osoba]])(using +case class DirectoryPage(data: EventStream[List[Osoba]])(using router: Router[Page] -): HtmlElement = - div( - cls := "max-w-7xl mx-auto", - //cls := "xl:order-first xl:flex xl:flex-col flex-shrink-0 w-96 border-r border-gray-200", - form( - cls := "p-4 mt-6 flex space-x-4", - action := "#", - div( - cls := "flex-1 min-w-0", - label( - forId := "search", - cls := "sr-only", - """Search""" - ), +) extends AppPage: + + def pageContent: HtmlElement = + div( + cls := "max-w-7xl mx-auto", + //cls := "xl:order-first xl:flex xl:flex-col flex-shrink-0 w-96 border-r border-gray-200", + form( + cls := "p-4 mt-6 flex space-x-4", + action := "#", div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - Icons.solid.search + cls := "flex-1 min-w-0", + label( + forId := "search", + cls := "sr-only", + """Search""" ), - input( - tpe := "search", - name := "search", - idAttr := "search", - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholder := "Search" + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + Icons.solid.search + ), + input( + tpe := "search", + name := "search", + idAttr := "search", + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholder := "Search" + ) + ) + ), + button( + tpe := "submit", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + Icons.solid.filter, + span( + cls := "sr-only", + """Search""" ) ) ), - button( - tpe := "submit", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - Icons.solid.filter, - span( - cls := "sr-only", - """Search""" - ) - ) - ), - nav( - cls := "flex-1 min-h-0 overflow-y-auto", - aria.label := "Directory", - div( - cls := "relative", - // TODO: group by surname + nav( + cls := "flex-1 min-h-0 overflow-y-auto", + aria.label := "Directory", div( - cls := "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500", - h3( - """A""" - ) - ), - ul( - role := "list", - cls := "relative z-0 divide-y divide-gray-200", - // TODO: zero / loading page - children <-- data.map(_.map({ o => - val page = Page.Detail(o.osobniCislo) - li( - div( - cls := "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500", + cls := "relative", + // TODO: group by surname + div( + cls := "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500", + h3( + """A""" + ) + ), + ul( + role := "list", + cls := "relative z-0 divide-y divide-gray-200", + // TODO: zero / loading page + children <-- data.map(_.map({ o => + val page = Page.Detail(o.osobniCislo) + li( div( - cls := "flex-shrink-0", - img( - cls := "h-10 w-10 rounded-full", - src := "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80", - alt := "" - ) - ), - div( - cls := "flex-1 min-w-0", - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "focus:outline-none", - span( - cls := "absolute inset-0", - aria.hidden := true - ), - p( - cls := "text-sm font-medium text-gray-900", - o.jmeno - ), - p( - cls := "text-sm text-gray-500 truncate", - o.hlavniFunkce.nazev + cls := "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500", + div( + cls := "flex-shrink-0", + img( + cls := "h-10 w-10 rounded-full", + src := "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80", + alt := "" + ) + ), + div( + cls := "flex-1 min-w-0", + a( + href := router.absoluteUrlForPage(page), + navigateTo(page), + cls := "focus:outline-none", + span( + cls := "absolute inset-0", + aria.hidden := true + ), + p( + cls := "text-sm font-medium text-gray-900", + o.jmeno + ), + p( + cls := "text-sm text-gray-500 truncate", + o.hlavniFunkce.nazev + ) ) ) ) ) - ) - })) + })) + ) ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala index f0cbc90..aa41c05 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala @@ -4,88 +4,90 @@ import com.raquo.laminar.api.L.{*, given} import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.app.Page -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator -def ErrorPage( +case class ErrorPage( + homePage: Page, errorName: String, title: String, subTitle: String -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full pt-16 pb-12 flex flex-col bg-white", - main( - cls := "flex-grow flex flex-col justify-center max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", - div( - cls := "flex-shrink-0 flex justify-center", - a( - href := "/", - cls := "inline-flex", - span( - cls := "sr-only", - """Workflow""" - ), - img( - cls := "h-12 w-auto", - src := "https://tailwindui.com/img/logos/workflow-mark.svg?color=indigo&shade=600", - alt := "" - ) - ) - ), - div( - cls := "py-16", +)(using router: Router[Page]) + extends Navigator[Page]: + def render: HtmlElement = + div( + cls := "min-h-full pt-16 pb-12 flex flex-col bg-white", + main( + cls := "flex-grow flex flex-col justify-center max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", div( - cls := "text-center", - p( - cls := "text-sm font-semibold text-indigo-600 uppercase tracking-wide", - errorName - ), - h1( - cls := "mt-2 text-4xl font-extrabold text-gray-900 tracking-tight sm:text-5xl", - title - ), - p( - cls := "mt-2 text-base text-gray-500", - subTitle - ), + cls := "flex-shrink-0 flex justify-center", + a( + href := "/", + cls := "inline-flex", + span( + cls := "sr-only", + """Workflow""" + ), + img( + cls := "h-12 w-auto", + src := "https://tailwindui.com/img/logos/workflow-mark.svg?color=indigo&shade=600", + alt := "" + ) + ) + ), + div( + cls := "py-16", div( - cls := "mt-6", - a( - href := router.absoluteUrlForPage(Page.Dashboard), - navigateTo(Page.Dashboard), - cls := "text-base font-medium text-indigo-600 hover:text-indigo-500", - """Go back home""" + cls := "text-center", + p( + cls := "text-sm font-semibold text-indigo-600 uppercase tracking-wide", + errorName + ), + h1( + cls := "mt-2 text-4xl font-extrabold text-gray-900 tracking-tight sm:text-5xl", + title + ), + p( + cls := "mt-2 text-base text-gray-500", + subTitle + ), + div( + cls := "mt-6", + a( + navigateTo(homePage), + cls := "text-base font-medium text-indigo-600 hover:text-indigo-500", + """Go back home""" + ) ) ) ) - ) - ), - footer( - cls := "flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", - nav( - cls := "flex justify-center space-x-4", - a( - href := "#", - cls := "text-sm font-medium text-gray-500 hover:text-gray-600", - """Contact Support""" - ), - span( - cls := "inline-block border-l border-gray-300", - aria.hidden := true - ), - a( - href := "#", - cls := "text-sm font-medium text-gray-500 hover:text-gray-600", - """Status""" - ), - span( - cls := "inline-block border-l border-gray-300", - aria.hidden := true - ), - a( - href := "#", - cls := "text-sm font-medium text-gray-500 hover:text-gray-600", - """Twitter""" + ), + footer( + cls := "flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", + nav( + cls := "flex justify-center space-x-4", + a( + href := "#", + cls := "text-sm font-medium text-gray-500 hover:text-gray-600", + """Contact Support""" + ), + span( + cls := "inline-block border-l border-gray-300", + aria.hidden := true + ), + a( + href := "#", + cls := "text-sm font-medium text-gray-500 hover:text-gray-600", + """Status""" + ), + span( + cls := "inline-block border-l border-gray-300", + aria.hidden := true + ), + a( + href := "#", + cls := "text-sm font-medium text-gray-500 hover:text-gray-600", + """Twitter""" + ) ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/NotFoundPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/NotFoundPage.scala index 901518f..787a250 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/NotFoundPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/NotFoundPage.scala @@ -5,11 +5,12 @@ import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.app.Page -def NotFoundPage(url: String)(using +def NotFoundPage(homePage: Page, url: String)(using router: Router[Page] ): HtmlElement = ErrorPage( + homePage, "404 error", "Page not found.", s"Sorry, but page $url doesn't exist." - ) + ).render diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/UnhandledErrorPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/UnhandledErrorPage.scala index cb5d80f..8d79ee3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/UnhandledErrorPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/UnhandledErrorPage.scala @@ -6,11 +6,13 @@ import cz.e_bs.cmi.mdr.pdb.app.Page def UnhandledErrorPage( + homePage: Page, errorName: Option[String], errorMessage: Option[String] )(using router: Router[Page]): HtmlElement = ErrorPage( + homePage, "Unexpected error occurred", errorName.getOrElse("Uh oh!"), // TODO: translations, better text than uh oh errorMessage.getOrElse("This wasn't supposed to happen! Please try again.") - ) + ).render diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala index 27ac1e3..7ba888c 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/Main.scala @@ -6,7 +6,6 @@ import scala.scalajs.js import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.components.{Navigation, Layout} import scala.scalajs.js.Date import com.raquo.waypoint.Router import com.raquo.waypoint.SplitRender @@ -36,15 +35,7 @@ val _ = render( appContainer, - Layout( - logo, - userProfile.signal, - // TODO: make static, use user profile to filter - allPages.signal, - // TODO: make static, use user profile to filter - userMenu.signal, - renderPage - ) + renderPage ) }(unsafeWindowOwner) } @@ -52,55 +43,33 @@ def renderPage(using router: Router[Page]): HtmlElement = val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage) .collectSignal[Page.Detail]( - pages.DetailPage(osc => - EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) - ) + pages + .DetailPage(osc => + EventStream.fromValue(ExampleData.persons.jmeistrova).delay(1000) + )(_) + .render ) - .collectStatic(Page.Dashboard)(pages.DashboardPage) - .collect[Page.NotFound](pg => pages.errors.NotFoundPage(pg.url)) + .collectStatic(Page.Dashboard)(pages.DashboardPage().render) + .collect[Page.NotFound](pg => + pages.errors.NotFoundPage(Routes.homePage, pg.url) + ) .collect[Page.UnhandledError](pg => pages.errors - .UnhandledErrorPage(pg.errorName, pg.errorMessage) + .UnhandledErrorPage( + Routes.homePage, + pg.errorName, + pg.errorMessage + ) ) .collectStatic(Page.Directory)( - pages.DirectoryPage( - EventStream - .fromValue(List(ExampleData.persons.jmeistrova)) - ) + pages + .DirectoryPage( + EventStream + .fromValue(List(ExampleData.persons.jmeistrova)) + ) + .render ) - components.MainSection(child <-- pageSplitter.$view) - - // TODO: pages by logged in user - val allPages = Var(List(Page.Directory, Page.Dashboard)) - - val logo = Navigation.Logo( - "Workflow", - "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" - ) - - // TODO: load user profile - val userProfile = Var( - UserProfile( - "tom", - UserInfo( - "Tom Cook", - "tom@example.com", - "+420 222 866 180", - None, - "ČMI Medical", - "ředitel" - ) - ) - ) - - // TODO: menu items by user profile - val userMenu = Var( - List( - Navigation.MenuItem("Your Profile"), - Navigation.MenuItem("Settings"), - Navigation.MenuItem("Sign out") - ) - ) + div(child <-- pageSplitter.$view) // Pull in the stylesheet val css: Css.type = Css 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 42dbda9..7b6cd55 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 @@ -33,9 +33,11 @@ .asInstanceOf[String] .init // Drop the ending slash + val homePage: Page = Page.Directory + val router = Router[Page]( routes = List( - Route.static(Page.Directory, root / endOfSegments, basePath = base), + Route.static(homePage, root / endOfSegments, basePath = base), Route.static( Page.Dashboard, root / "dashboard" / endOfSegments, @@ -58,25 +60,3 @@ $popStateEvent = windowEvents.onPopState, owner = unsafeWindowOwner ) - - // TODO: evaluate dangers of a global router in a SPA - def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] = - Binder { el => - - val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] - - if (isLinkElement) { - el.amend(href(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 - (onClick - .filter(ev => - !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) - ) - .preventDefault - --> (_ => router.pushState(page))).bind(el) - } 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 new file mode 100644 index 0000000..30c92e7 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/AppPage.scala @@ -0,0 +1,49 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.app.Page +import cz.e_bs.cmi.mdr.pdb.app.{UserProfile, UserInfo => ModelUserInfo} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator + +trait AppPage + extends PageLayout + with PageHeader + with Breadcrumbs + with NavigationBar[Page] + with Navigator[Page]: + // TODO: pages by logged in user + val pages = List(Page.Directory, Page.Dashboard) + + override val logo = Logo( + "https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg", + "Workflow" + ) + + // TODO: menu items by user profile + override 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( + "tom", + ModelUserInfo( + "Tom Cook", + "tom@example.com", + "+420 222 866 180", + None, + "ČMI Medical", + "ředitel" + ) + ) + ) + + override val $userInfo = $userProfile.signal.map(p => + UserInfo(p.userInfo.name, p.userInfo.email, p.userInfo.img) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala index 172d3a2..ae0b06f 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Avatar.scala @@ -1,19 +1,32 @@ package cz.e_bs.cmi.mdr.pdb.app.components +import CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec // TODO: render icon or picture based on img signal -def Avatar($img: Signal[Option[String]], size: Int = 8) = - div( - cls := "relative", - img( - cls := "h-16 w-16 rounded-full", - src := "https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80", - alt := "" - ), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) ) - ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) 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 08a9b3e..20393ef 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 @@ -1,15 +1,24 @@ package cz.e_bs.cmi.mdr.pdb.app.components import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.Page -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.codecs.StringAsIsCodec import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes +import CustomAttrs.svg.ariaHidden +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import cz.e_bs.cmi.mdr.pdb.app.Page -def Breadcrumbs(using router: Router[Page]): HtmlElement = +trait Breadcrumbs(using router: Router[Page]): + self: Navigator[Page] => - def renderFull(page: Page): HtmlElement = + def breadcrumbs: HtmlElement = + val $p = router.$currentPage + nav( + cls := "flex", + aria.label := "Breadcrumb", + child <-- $p.map(renderShort), + child <-- $p.map(renderFull) + ) + + private def renderFull(page: Page): HtmlElement = div( cls := "hidden sm:block", ol( @@ -19,7 +28,7 @@ ) ) - def renderShort(page: Page): HtmlElement = + private def renderShort(page: Page): HtmlElement = div( cls := "flex sm:hidden", page.parent match { @@ -27,7 +36,7 @@ case Some(p) => a( href := router.absoluteUrlForPage(p), - Routes.navigateTo(p), + 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) @@ -35,7 +44,7 @@ } ) - def renderItems(page: Page): Seq[HtmlElement] = + private def renderItems(page: Page): Seq[HtmlElement] = page.parent match { case None => Seq(li(div(renderHome(page)))) case Some(p) => @@ -52,34 +61,25 @@ ) } - def renderHome(page: Page) = + private def renderHome(page: Page) = a( href := router.absoluteUrlForPage(page), - Routes.navigateTo(page), + navigateTo(page), cls := "text-gray-400 hover:text-gray-500", Icons.solid.home, span(cls := "sr-only", "Home") ) - def slash = { + private def slash = { import svg.{*, given} svg( cls := "flex-shrink-0 h-5 w-5 text-gray-300", xmlns := "http://www.w3.org/2000/svg", fill := "currentColor", viewBox := "0 0 20 20", - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) := true, + ariaHidden := true, path( d := "M5.555 17.776l8-16 .894.448-8 16-.894-.448z" ) ) } - - val $p = router.$currentPage - - nav( - cls := "flex", - aria.label := "Breadcrumb", - child <-- $p.map(renderShort), - child <-- $p.map(renderFull) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala new file mode 100644 index 0000000..1da4460 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/CustomAttrs.scala @@ -0,0 +1,19 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala index cc6c192..554cc8a 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Icons.scala @@ -10,10 +10,8 @@ object Icons: val defaultSize: Int = 6 - // TODO: remove aria-hidden from here, move to call sites, it has no reason to be here. or does it? - // Who decides whether the icon should be hidden? Or should the icon be hidden always? object aria: - val hidden = customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + inline def hidden = CustomAttrs.svg.ariaHidden object outline: def bell = diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala deleted file mode 100644 index 7e8312e..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Layout.scala +++ /dev/null @@ -1,40 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router - -def PageHeader(using router: 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 - ) - ) - ) - -def MainSection(mods: Modifier[HtmlElement]*): HtmlElement = - main(mods) - -def Layout( - logo: Navigation.Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[Navigation.MenuItem]], - content: HtmlElement -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full", - Navigation( - logo, - profile, - pages, - userMenu - ), - PageHeader, - content - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala deleted file mode 100644 index e518682..0000000 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/Navigation.scala +++ /dev/null @@ -1,256 +0,0 @@ -package cz.e_bs.cmi.mdr.pdb.app.components - -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.api.L.{*, given} -import cz.e_bs.cmi.mdr.pdb.app.{Page, UserProfile} -import com.raquo.waypoint.Router -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo - -object Navigation: - - case class Logo(name: String, img: String) - - case class MenuItem(title: String) - - given Conversion[Navigation, HtmlElement] = _.render - -import Navigation._ - -case class Navigation( - logo: Logo, - profile: Signal[UserProfile], - pages: Signal[List[Page]], - userMenu: Signal[List[MenuItem]] -)(using router: Router[Page]): - val mobileMenuOpen = Var(false) - - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - - val desktopOnly = cls("hidden md:block") - val mobileOnly = cls("md:hidden") - - def render: HtmlElement = - nav(cls := "bg-indigo-600", navBar, mobileMenu) - - 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 - ) - - private inline def avatar(size: Int = 8) = - profile.map(_.userInfo.img match { - case Some(url) => - img( - cls := s"w-$size h-$size rounded-full", - src := url, - alt := "" - ) - case None => - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", - Icons.outline.user(size - 2) - ) - }) - - private def userProfile: HtmlElement = - val menuOpen = 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 - ) - - 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 <-- avatar(), - onClick.preventDefault.mapTo( - !menuOpen.now() - ) --> menuOpen.writer - ) - ), - /* - * */ - 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 <-- userMenu.map(_.zipWithIndex.map(menuItem)) - ) - ) - - 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 - ) - - div( - cls := "pt-4 pb-3 border-t border-indigo-700", - div( - cls := "flex items-center px-5", - div( - cls := "flex-shrink-0", - child <-- avatar(10) - ), - div( - cls := "ml-3", - div( - cls := "text-base font-medium text-white", - child.text <-- profile.map(_.userInfo.name) - ), - div( - cls := "text-sm font-medium text-indigo-300", - child.text <-- profile.map(_.userInfo.email) - ) - ), - notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) - ), - div( - cls := "mt-3 px-2 space-y-1", - children <-- userMenu.map(_.map(menuItem)) - ) - ) - - private def pageLink(page: Page, active: Signal[Boolean])(using - router: Router[Page] - ): Anchor = - a( - href := router.absoluteUrlForPage(page), - 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 - ) - - private def logoImg: Image = - img( - cls := "h-8 w-8", - src := logo.img, - alt := logo.name - ) - - private def pageLinks(mods: Modifier[HtmlElement]*) = pages.map( - _.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), - div( - desktopOnly, - div( - cls := "ml-10 flex items-baseline space-x-4", - children <-- pageLinks() - ) - ) - ) - - private def navBarRight = - div( - desktopOnly, - div( - cls := "ml-4 flex items-center md:ml-6", - notificationButton, - userProfile - ) - ) - - 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", - div( - cls := "flex items-center justify-between h-16", - navBarLeft, - navBarRight, - navBarMobile - ) - ) - - def mobileMenu = - div( - 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 - ) 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 new file mode 100644 index 0000000..593d88f --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/NavigationBar.scala @@ -0,0 +1,236 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import CustomAttrs.ariaCurrent +import com.raquo.waypoint.Router + +trait NavigationBar[Page](using router: Router[Page]): + self: Navigator[Page] => + + case class Logo(img: String, name: String) + case class MenuItem(title: String) + case class UserInfo(name: String, email: String, img: Option[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 + ) + + private def userProfile: HtmlElement = + val menuOpen = 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 + ) + + 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 + ) + ), + /* + * */ + 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) + ) + ) + + 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 + ) + + 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) + ), + div( + cls := "text-sm font-medium text-indigo-300", + child.text <-- $userInfo.map(_.email) + ) + ), + notificationButton.amend(cls := List("flex-shrink-0", "ml-auto")) + ), + div( + cls := "mt-3 px-2 space-y-1", + userMenu.map(menuItem) + ) + ) + + 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) + ) + + 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), + div( + desktopOnly, + div( + cls := "ml-10 flex items-baseline space-x-4", + pageLinks() + ) + ) + ) + + private def navBarRight = + div( + desktopOnly, + div( + cls := "ml-4 flex items-center md:ml-6", + notificationButton, + userProfile + ) + ) + + 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", + 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", + div( + cls := "px-2 pt-2 pb-3 space-y-1 sm:px-3", + pageLinks(cls := "block") + ), + mobileProfile + ) 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 new file mode 100644 index 0000000..122dfd2 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageHeader.scala @@ -0,0 +1,20 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +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 + +trait PageHeader: + self: Breadcrumbs with Navigator[Page] => + + def pageHeader: 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 + ) + ) + ) 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 new file mode 100644 index 0000000..fcb89aa --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/PageLayout.scala @@ -0,0 +1,17 @@ +package cz.e_bs.cmi.mdr.pdb.app.components + +import com.raquo.laminar.api.L.{*, given} + +trait PageLayout { + def navigation: HtmlElement + def pageHeader: HtmlElement + def pageContent: HtmlElement + + def render: HtmlElement = + div( + cls := "min-h-full", + navigation, + pageHeader, + main(pageContent) + ) +} diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala new file mode 100644 index 0000000..9e8be6d --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/components/list/BaseList.scala @@ -0,0 +1,55 @@ +package cz.e_bs.cmi.mdr.pdb.app.components.list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.Icons +import cz.e_bs.cmi.mdr.pdb.app.Routes +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait BaseList[RowData]: + + type RenderRow = Signal[RowData] => Modifier[HtmlElement] + + inline protected def containerElement: HtmlTag[dom.html.Element] = a + + protected val containerMods: RenderRow + protected val title: RenderRow + protected val topRight: RenderRow + protected val bottomLeft: RenderRow + protected val bottomRight: RenderRow + + def row($data: Signal[RowData]) = + li( + containerElement( + containerMods($data), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + title($data) + ), + div( + cls := "ml-2 flex-shrink-0 flex", + topRight($data) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + bottomLeft($data), + bottomRight($data) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala index fdf353c..cc59bee 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DashboardPage.scala @@ -1,6 +1,10 @@ package cz.e_bs.cmi.mdr.pdb.app.pages import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage +import cz.e_bs.cmi.mdr.pdb.app.Page -def DashboardPage: HtmlElement = - div("Dashboard page") +class DashboardPage(using router: Router[Page]) extends AppPage: + override def pageContent: HtmlElement = + div("Dashboard page") diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala index d299408..077ece7 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DetailPage.scala @@ -9,14 +9,16 @@ import cz.e_bs.cmi.mdr.pdb.app.services.DataFetcher import com.raquo.airstream.core.EventStream import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.CustomAttrs.datetime +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage -val datetime = customHtmlAttr("datetime", StringAsIsCodec) - -def DetailPage(fetch: String => EventStream[Osoba])( +case class DetailPage(fetch: String => EventStream[Osoba])( $page: Signal[Page.Detail] -)(using router: Router[Page]): HtmlElement = +)(using + router: Router[Page] +) extends AppPage: // TODO: proper loader - val loading = + private val loading = div( cls := "bg-gray-50 overflow-hidden rounded-lg", div( @@ -24,206 +26,210 @@ "Loading..." ) ) - val data = Var[Option[Osoba]](None) - val $maybeOsoba = data.signal.split(_ => ())((_, _, s) => OsobaView(s)) - val $fetchedData = $page.splitOne(_.osobniCislo)((osc, _, _) => osc) - .flatMap(fetch) - .debugLog() - div( - cls := "max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8", - $fetchedData --> data.writer.contramapSome, - $fetchedData --> (o => router.replaceState(Page.Detail(o))), - child <-- $maybeOsoba.map(_.getOrElse(loading)) - ) -def OsobaView($osoba: Signal[Osoba]): HtmlElement = - def funkce($fce: Signal[Funkce]) = - p( - cls := "text-sm font-medium text-gray-500", - child.text <-- $fce.map(_.nazev), - span( - cls := "hidden md:inline", - " @ ", - child.text <-- $fce.map(_.stredisko), - ", ", - child.text <-- $fce.map(_.voj) - ) - ) - - def pp($pp: Signal[PracovniPomer]) = - p( - cls := "text-sm font-medium text-gray-500", - child.text <-- $pp.map(_.druh), - " od ", - time( - datetime <-- $pp.map(_.pocatek.toString), - child.text <-- $pp.map(_.pocatek.toString) - ) - ) - - div( - cls := "flex flex-col space-y-4", + override def pageContent: HtmlElement = + val data = Var[Option[Osoba]](None) + val $maybeOsoba = + data.signal.split(_ => ())((_, _, s) => osobaView(s)) + val $fetchedData = $page.splitOne(_.osobniCislo)((osc, _, _) => osc) + .flatMap(fetch) + .debugLog() div( - cls := "md:flex md:items-center md:justify-between md:space-x-5", - div( - cls := "flex items-start space-x-5", - div( - cls := "flex-shrink-0", - Avatar($osoba.map(_.img), 16) - ), - div( - h1( - cls := "text-2xl font-bold text-gray-900", - child.text <-- $osoba.map(_.jmeno) - ), - funkce($osoba.map(_.hlavniFunkce)), - pp($osoba.map(_.pracovniPomer)) + cls := "max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8", + $fetchedData --> data.writer.contramapSome, + $fetchedData --> (o => router.replaceState(Page.Detail(o))), + child <-- $maybeOsoba.map(_.getOrElse(loading)) + ) + + private def osobaView($osoba: Signal[Osoba]): HtmlElement = + def funkce($fce: Signal[Funkce]) = + p( + cls := "text-sm font-medium text-gray-500", + child.text <-- $fce.map(_.nazev), + span( + cls := "hidden md:inline", + " @ ", + child.text <-- $fce.map(_.stredisko), + ", ", + child.text <-- $fce.map(_.voj) ) ) - ), + + def pp($pp: Signal[PracovniPomer]) = + p( + cls := "text-sm font-medium text-gray-500", + child.text <-- $pp.map(_.druh), + " od ", + time( + datetime <-- $pp.map(_.pocatek.toString), + child.text <-- $pp.map(_.pocatek.toString) + ) + ) + div( - cls := "bg-white shadow overflow-hidden sm:rounded-md", - ul( - role := "list", - cls := "divide-y divide-gray-200", - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", + cls := "flex flex-col space-y-4", + div( + cls := "md:flex md:items-center md:justify-between md:space-x-5", + div( + cls := "flex items-start space-x-5", + div( + cls := "flex-shrink-0", + Avatar($osoba.map(_.img)).avatar(16) + ), + div( + h1( + cls := "text-2xl font-bold text-gray-900", + child.text <-- $osoba.map(_.jmeno) + ), + funkce($osoba.map(_.hlavniFunkce)), + pp($osoba.map(_.pracovniPomer)) + ) + ) + ), + div( + cls := "bg-white shadow overflow-hidden sm:rounded-md", + ul( + role := "list", + cls := "divide-y divide-gray-200", + li( + a( + href := "#", + cls := "block hover:bg-gray-50", div( - cls := "min-w-0 flex-1 pr-4", + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + "Komise pro pověřování pracovníků" + ), + div( + cls := "ml-2 flex-shrink-0 flex", + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", + """Splněno""" + ) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div(), + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + Icons.solid.calendar, + p( + """do """, + time( + datetime := "2020-01-07", + "01.07.2020" + ) + ) + ) + ) + ), + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right` + ) + ) + ) + ), + li( + a( + href := "#", + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6", div( cls := "flex items-center justify-between", p( cls := "text-sm font-medium text-indigo-600 truncate", - "Komise pro pověřování pracovníků" + """Front End Developer""" ), div( cls := "ml-2 flex-shrink-0 flex", p( cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Splněno""" + """Full-time""" ) ) ), div( cls := "mt-2 sm:flex sm:justify-between", - div(), + div( + cls := "sm:flex", + p( + cls := "flex items-center text-sm text-gray-500", + Icons.solid.users, + """Engineering""" + ), + p( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", + Icons.solid.`location-marker`, + """Remote""" + ) + ), div( cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", Icons.solid.calendar, p( - """do """, + """Closing on""", time( datetime := "2020-01-07", - "01.07.2020" + """January 7, 2020""" ) ) ) ) - ), - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right` ) ) - ) - ), - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6", + ), + li( + a( + href := "#", + cls := "block hover:bg-gray-50", div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - """Front End Developer""" - ), + cls := "px-4 py-4 sm:px-6", div( - cls := "ml-2 flex-shrink-0 flex", + cls := "flex items-center justify-between", p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Full-time""" - ) - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - div( - cls := "sm:flex", - p( - cls := "flex items-center text-sm text-gray-500", - Icons.solid.users, - """Engineering""" + cls := "text-sm font-medium text-indigo-600 truncate", + """User Interface Designer""" ), - p( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", - Icons.solid.`location-marker`, - """Remote""" - ) - ), - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - Icons.solid.calendar, - p( - """Closing on""", - time( - datetime := "2020-01-07", - """January 7, 2020""" + div( + cls := "ml-2 flex-shrink-0 flex", + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", + """Full-time""" ) ) - ) - ) - ) - ) - ), - li( - a( - href := "#", - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - """User Interface Designer""" ), div( - cls := "ml-2 flex-shrink-0 flex", - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800", - """Full-time""" - ) - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - div( - cls := "sm:flex", - p( - cls := "flex items-center text-sm text-gray-500", - Icons.solid.users, - """Design""" + cls := "mt-2 sm:flex sm:justify-between", + div( + cls := "sm:flex", + p( + cls := "flex items-center text-sm text-gray-500", + Icons.solid.users, + """Design""" + ), + p( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", + Icons.solid.`location-marker`, + """Remote""" + ) ), - p( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6", - Icons.solid.`location-marker`, - """Remote""" - ) - ), - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - Icons.solid.calendar, - p( - """Closing on""", - time( - datetime := "2020-01-14", - """January 14, 2020""" + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + Icons.solid.calendar, + p( + """Closing on""", + time( + datetime := "2020-01-14", + """January 14, 2020""" + ) ) ) ) @@ -233,4 +239,3 @@ ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala index 0b2eb22..12d2bc0 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/DirectoryPage.scala @@ -3,104 +3,106 @@ import com.raquo.laminar.api.L.{*, given} import cz.e_bs.cmi.mdr.pdb.app.components.Icons import cz.e_bs.cmi.mdr.pdb.app.Osoba -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo import cz.e_bs.cmi.mdr.pdb.app.Page import com.raquo.waypoint.Router +import cz.e_bs.cmi.mdr.pdb.app.components.AppPage -def DirectoryPage(data: EventStream[List[Osoba]])(using +case class DirectoryPage(data: EventStream[List[Osoba]])(using router: Router[Page] -): HtmlElement = - div( - cls := "max-w-7xl mx-auto", - //cls := "xl:order-first xl:flex xl:flex-col flex-shrink-0 w-96 border-r border-gray-200", - form( - cls := "p-4 mt-6 flex space-x-4", - action := "#", - div( - cls := "flex-1 min-w-0", - label( - forId := "search", - cls := "sr-only", - """Search""" - ), +) extends AppPage: + + def pageContent: HtmlElement = + div( + cls := "max-w-7xl mx-auto", + //cls := "xl:order-first xl:flex xl:flex-col flex-shrink-0 w-96 border-r border-gray-200", + form( + cls := "p-4 mt-6 flex space-x-4", + action := "#", div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - Icons.solid.search + cls := "flex-1 min-w-0", + label( + forId := "search", + cls := "sr-only", + """Search""" ), - input( - tpe := "search", - name := "search", - idAttr := "search", - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholder := "Search" + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + Icons.solid.search + ), + input( + tpe := "search", + name := "search", + idAttr := "search", + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholder := "Search" + ) + ) + ), + button( + tpe := "submit", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + Icons.solid.filter, + span( + cls := "sr-only", + """Search""" ) ) ), - button( - tpe := "submit", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - Icons.solid.filter, - span( - cls := "sr-only", - """Search""" - ) - ) - ), - nav( - cls := "flex-1 min-h-0 overflow-y-auto", - aria.label := "Directory", - div( - cls := "relative", - // TODO: group by surname + nav( + cls := "flex-1 min-h-0 overflow-y-auto", + aria.label := "Directory", div( - cls := "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500", - h3( - """A""" - ) - ), - ul( - role := "list", - cls := "relative z-0 divide-y divide-gray-200", - // TODO: zero / loading page - children <-- data.map(_.map({ o => - val page = Page.Detail(o.osobniCislo) - li( - div( - cls := "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500", + cls := "relative", + // TODO: group by surname + div( + cls := "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500", + h3( + """A""" + ) + ), + ul( + role := "list", + cls := "relative z-0 divide-y divide-gray-200", + // TODO: zero / loading page + children <-- data.map(_.map({ o => + val page = Page.Detail(o.osobniCislo) + li( div( - cls := "flex-shrink-0", - img( - cls := "h-10 w-10 rounded-full", - src := "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80", - alt := "" - ) - ), - div( - cls := "flex-1 min-w-0", - a( - href := router.absoluteUrlForPage(page), - navigateTo(page), - cls := "focus:outline-none", - span( - cls := "absolute inset-0", - aria.hidden := true - ), - p( - cls := "text-sm font-medium text-gray-900", - o.jmeno - ), - p( - cls := "text-sm text-gray-500 truncate", - o.hlavniFunkce.nazev + cls := "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500", + div( + cls := "flex-shrink-0", + img( + cls := "h-10 w-10 rounded-full", + src := "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80", + alt := "" + ) + ), + div( + cls := "flex-1 min-w-0", + a( + href := router.absoluteUrlForPage(page), + navigateTo(page), + cls := "focus:outline-none", + span( + cls := "absolute inset-0", + aria.hidden := true + ), + p( + cls := "text-sm font-medium text-gray-900", + o.jmeno + ), + p( + cls := "text-sm text-gray-500 truncate", + o.hlavniFunkce.nazev + ) ) ) ) ) - ) - })) + })) + ) ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala index f0cbc90..aa41c05 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/ErrorPage.scala @@ -4,88 +4,90 @@ import com.raquo.laminar.api.L.{*, given} import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.app.Page -import cz.e_bs.cmi.mdr.pdb.app.Routes.navigateTo +import cz.e_bs.cmi.mdr.pdb.waypoint.components.Navigator -def ErrorPage( +case class ErrorPage( + homePage: Page, errorName: String, title: String, subTitle: String -)(using router: Router[Page]): HtmlElement = - div( - cls := "min-h-full pt-16 pb-12 flex flex-col bg-white", - main( - cls := "flex-grow flex flex-col justify-center max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", - div( - cls := "flex-shrink-0 flex justify-center", - a( - href := "/", - cls := "inline-flex", - span( - cls := "sr-only", - """Workflow""" - ), - img( - cls := "h-12 w-auto", - src := "https://tailwindui.com/img/logos/workflow-mark.svg?color=indigo&shade=600", - alt := "" - ) - ) - ), - div( - cls := "py-16", +)(using router: Router[Page]) + extends Navigator[Page]: + def render: HtmlElement = + div( + cls := "min-h-full pt-16 pb-12 flex flex-col bg-white", + main( + cls := "flex-grow flex flex-col justify-center max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", div( - cls := "text-center", - p( - cls := "text-sm font-semibold text-indigo-600 uppercase tracking-wide", - errorName - ), - h1( - cls := "mt-2 text-4xl font-extrabold text-gray-900 tracking-tight sm:text-5xl", - title - ), - p( - cls := "mt-2 text-base text-gray-500", - subTitle - ), + cls := "flex-shrink-0 flex justify-center", + a( + href := "/", + cls := "inline-flex", + span( + cls := "sr-only", + """Workflow""" + ), + img( + cls := "h-12 w-auto", + src := "https://tailwindui.com/img/logos/workflow-mark.svg?color=indigo&shade=600", + alt := "" + ) + ) + ), + div( + cls := "py-16", div( - cls := "mt-6", - a( - href := router.absoluteUrlForPage(Page.Dashboard), - navigateTo(Page.Dashboard), - cls := "text-base font-medium text-indigo-600 hover:text-indigo-500", - """Go back home""" + cls := "text-center", + p( + cls := "text-sm font-semibold text-indigo-600 uppercase tracking-wide", + errorName + ), + h1( + cls := "mt-2 text-4xl font-extrabold text-gray-900 tracking-tight sm:text-5xl", + title + ), + p( + cls := "mt-2 text-base text-gray-500", + subTitle + ), + div( + cls := "mt-6", + a( + navigateTo(homePage), + cls := "text-base font-medium text-indigo-600 hover:text-indigo-500", + """Go back home""" + ) ) ) ) - ) - ), - footer( - cls := "flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", - nav( - cls := "flex justify-center space-x-4", - a( - href := "#", - cls := "text-sm font-medium text-gray-500 hover:text-gray-600", - """Contact Support""" - ), - span( - cls := "inline-block border-l border-gray-300", - aria.hidden := true - ), - a( - href := "#", - cls := "text-sm font-medium text-gray-500 hover:text-gray-600", - """Status""" - ), - span( - cls := "inline-block border-l border-gray-300", - aria.hidden := true - ), - a( - href := "#", - cls := "text-sm font-medium text-gray-500 hover:text-gray-600", - """Twitter""" + ), + footer( + cls := "flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8", + nav( + cls := "flex justify-center space-x-4", + a( + href := "#", + cls := "text-sm font-medium text-gray-500 hover:text-gray-600", + """Contact Support""" + ), + span( + cls := "inline-block border-l border-gray-300", + aria.hidden := true + ), + a( + href := "#", + cls := "text-sm font-medium text-gray-500 hover:text-gray-600", + """Status""" + ), + span( + cls := "inline-block border-l border-gray-300", + aria.hidden := true + ), + a( + href := "#", + cls := "text-sm font-medium text-gray-500 hover:text-gray-600", + """Twitter""" + ) ) ) ) - ) diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/NotFoundPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/NotFoundPage.scala index 901518f..787a250 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/NotFoundPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/NotFoundPage.scala @@ -5,11 +5,12 @@ import com.raquo.waypoint.Router import cz.e_bs.cmi.mdr.pdb.app.Page -def NotFoundPage(url: String)(using +def NotFoundPage(homePage: Page, url: String)(using router: Router[Page] ): HtmlElement = ErrorPage( + homePage, "404 error", "Page not found.", s"Sorry, but page $url doesn't exist." - ) + ).render diff --git a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/UnhandledErrorPage.scala b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/UnhandledErrorPage.scala index cb5d80f..8d79ee3 100644 --- a/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/UnhandledErrorPage.scala +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/app/pages/errors/UnhandledErrorPage.scala @@ -6,11 +6,13 @@ import cz.e_bs.cmi.mdr.pdb.app.Page def UnhandledErrorPage( + homePage: Page, errorName: Option[String], errorMessage: Option[String] )(using router: Router[Page]): HtmlElement = ErrorPage( + homePage, "Unexpected error occurred", errorName.getOrElse("Uh oh!"), // TODO: translations, better text than uh oh errorMessage.getOrElse("This wasn't supposed to happen! Please try again.") - ) + ).render 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 new file mode 100644 index 0000000..93dd255 --- /dev/null +++ b/app/src/main/scala/cz/e_bs/cmi/mdr/pdb/waypoint/components/Navigator.scala @@ -0,0 +1,27 @@ +package cz.e_bs.cmi.mdr.pdb.waypoint.components + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import org.scalajs.dom + +trait Navigator[P](using router: Router[P]): + def navigateTo(page: P): Binder[HtmlElement] = + Binder { el => + + val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] + + if (isLinkElement) { + el.amend(href(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 + (onClick + .filter(ev => + !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) + ) + .preventDefault + --> (_ => router.pushState(page))).bind(el) + }