From 7f9815e83fa072e537772de3ba32bc35e6466243 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Cl=C3=A9ment=20Pit-Claudel?= <clement.pit-claudel@epfl.ch>
Date: Sun, 22 Dec 2024 16:07:38 +0100
Subject: [PATCH] client: Allow customization of banner and home page

---
 .../scala/cs214/webapp/client/Pages.scala     | 60 ++++++++++---------
 .../scala/cs214/webapp/client/Router.scala    | 10 ++--
 .../scala/cs214/webapp/client/WebClient.scala |  2 +-
 .../src/main/scala/cs214/webapp/Common.scala  |  3 +
 4 files changed, 42 insertions(+), 33 deletions(-)

diff --git a/js/src/main/scala/cs214/webapp/client/Pages.scala b/js/src/main/scala/cs214/webapp/client/Pages.scala
index 9603741..99c093e 100644
--- a/js/src/main/scala/cs214/webapp/client/Pages.scala
+++ b/js/src/main/scala/cs214/webapp/client/Pages.scala
@@ -4,7 +4,6 @@ package client
 import java.net.URLEncoder
 import scala.concurrent.ExecutionContext.Implicits.global
 import org.scalajs.dom
-import org.scalajs.dom.{Element, KeyCode}
 import org.scalajs.dom.html.{Input, Select, TextArea}
 import scalatags.JsDom.all.*
 import scalatags.JsDom.tags2.{article, section}
@@ -15,7 +14,7 @@ abstract class Page:
   def renderInto(target: dom.Element): Unit
 
   def pageHeader(subtitle: String) =
-    frag(h1(cls := "title", "ScalApp"), h2(subtitle))
+    frag(h1(cls := "title", Config.BANNER), h2(subtitle))
 
   def replaceChildren(target: dom.Element)(frag: Frag): Unit =
     target.replaceChildren(frag.render)
@@ -26,32 +25,39 @@ object Page:
   def from(url: String): Page = WebClient.route(url)
 
 /** The initial home page which fetches the list of apps */
-object HomePage extends Page:
-  val classList = "HomePage"
+abstract class AppCatalogPage extends Page:
+  def render(apps: Seq[AppInfo]): Frag
 
   def selectApp(appId: AppId): Unit =
     WebClient.navigateTo(InstanceCreationPage(appId))
 
-  def renderInto(target: Element) =
+  def renderApp(appInfo: AppInfo): Frag =
+    figure(
+      onclick := (() => selectApp(appInfo.id)),
+      img(src := s"/static/${appInfo.id}.png"),
+      figcaption(
+        h4(appInfo.name),
+        p(appInfo.description)
+      )
+    )
+
+  override def renderInto(target: dom.Element) =
     Requests.listApps.map: resp =>
-      doRender(resp.apps)(target)
+      replaceChildren(target):
+        render(resp.apps)
 
-  private def doRender(apps: Seq[AppInfo])(target: Element) = replaceChildren(target):
+object HomePage extends AppCatalogPage:
+  val classList = "HomePage"
+
+  override def render(appInfos: Seq[AppInfo]): Frag =
     frag(
-      pageHeader("Select an app"),
-      apps.groupBy(_.year).toList.map: (year, appsOfYear) =>
+      pageHeader("Pick an app!"),
+      appInfos.groupBy(_.year).toList.map: (year, apps) =>
         article(cls := "app-list",
-          h3(year.toString),
+          h2(year.toString),
           section(cls := "app-grid",
-            appsOfYear.sortBy(_.name).map: appInfo =>
-              figure(
-                onclick := (() => selectApp(appInfo.id)),
-                img(src := s"/static/${appInfo.id}.png"),
-                figcaption(
-                  h4(appInfo.name),
-                  p(appInfo.description)
-                )
-              )
+            apps.sortBy(_.name).map: appInfo =>
+              renderApp(appInfo)
           )
       )
     )
@@ -67,7 +73,7 @@ case class InstanceCreationPage(appId: AppId) extends Page:
     Requests.createInstance(appId, userIds).map: resp =>
       WebClient.navigateTo(UIPage(appId, resp.instanceId))
 
-  def renderInto(target: Element) = replaceChildren(target):
+  def renderInto(target: dom.Element) = replaceChildren(target):
     frag(
       pageHeader(s"Create a new $appId instance"),
       form(
@@ -96,7 +102,7 @@ case class UIPage(appId: AppId, instanceId: InstanceId) extends Page:
   private def cssId(idx: Int) = f"ui-$idx"
 
   private val handleKeyboardEvent: Function1[dom.KeyboardEvent, Unit] = (e: dom.KeyboardEvent) =>
-    if e.keyCode == KeyCode.Enter then
+    if e.keyCode == dom.KeyCode.Enter then
       e.preventDefault()
       joinPage()
 
@@ -113,7 +119,7 @@ case class UIPage(appId: AppId, instanceId: InstanceId) extends Page:
     getSelected.map: ui =>
       WebClient.navigateTo(JoinPage(appId, instanceId, ui.uiId))
 
-  def renderInto(target: Element) =
+  def renderInto(target: dom.Element) =
     require(appUIs.nonEmpty, f"No UI found for app with id $appId.")
     if appUIs.size == 1 then
       WebClient.navigateTo(JoinPage(appId, instanceId, appUIs(0).uiId), overwriteHistory = true)
@@ -145,7 +151,7 @@ case class UIPage(appId: AppId, instanceId: InstanceId) extends Page:
 /** The pre-connection menu, which fetches the user list. */
 case class JoinPage(appId: AppId, instanceId: InstanceId, uiId: UIId) extends Page:
   val classList = "JoinPageLoader"
-  def renderInto(target: Element) =
+  def renderInto(target: dom.Element) =
     Requests.instanceInfo(instanceId).map: resp =>
       JoinPage_(appId, instanceId, uiId, resp.userIds).renderInto(target)
 
@@ -158,7 +164,7 @@ case class JoinPage_(appId: AppId, instanceId: InstanceId, uiId: UIId, userIds:
   private def cssId(idx: Int) = f"user-$idx"
 
   private val handleKeyboardEvent: Function1[dom.KeyboardEvent, Unit] = (e: dom.KeyboardEvent) =>
-    if e.keyCode == KeyCode.Enter then
+    if e.keyCode == dom.KeyCode.Enter then
       e.preventDefault()
       joinAppInstance()
 
@@ -175,7 +181,7 @@ case class JoinPage_(appId: AppId, instanceId: InstanceId, uiId: UIId, userIds:
     getSelected.map: userId =>
       WebClient.navigateTo(AppPage(appId, instanceId, uiId, userId))
 
-  def renderInto(target: Element): Unit =
+  def renderInto(target: dom.Element): Unit =
     if userIds.size == 1 then
       WebClient.navigateTo(AppPage(appId, instanceId, uiId, userIds(0)), overwriteHistory = true)
     else replaceChildren(target):
@@ -231,7 +237,7 @@ case class AppPage(appId: AppId, instanceId: InstanceId, uiId: UIId, userId: Use
       .replace("{{authority}}", hostname + (if port.nonEmpty then f":$port" else ""))
       .replace("{{userId}}", URLEncoder.encode(userId, "UTF-8"))
 
-  def renderInto(target: Element) =
+  def renderInto(target: dom.Element) =
     replaceChildren(target):
       frag(header(id := "banner"), tag("section")(id := "app"))
     Requests.instanceInfo(instanceId).map: appInfo =>
@@ -244,7 +250,7 @@ case class AppPage(appId: AppId, instanceId: InstanceId, uiId: UIId, userId: Use
 case class IpBanner(shareUrl: String) extends Page:
   val classList = "IpBanner"
 
-  def renderInto(target: Element) =
+  def renderInto(target: dom.Element) =
     replaceChildren(target):
       val copyToClipboardButtonText = "📋 Copy to clipboard."
       frag(
diff --git a/js/src/main/scala/cs214/webapp/client/Router.scala b/js/src/main/scala/cs214/webapp/client/Router.scala
index 103b817..067fb07 100644
--- a/js/src/main/scala/cs214/webapp/client/Router.scala
+++ b/js/src/main/scala/cs214/webapp/client/Router.scala
@@ -4,8 +4,8 @@ package client
 import java.net.{URLDecoder, URLEncoder}
 
 abstract class Router:
-  protected def path(page: Page): Seq[String]
-  protected def page(path: Seq[String]): Page
+  protected def path(page: Page): List[String]
+  protected def page(path: List[String]): Page
 
   def url(page: Page): String =
     val fragments = path(page).map(s => URLEncoder.encode(s, "UTF-8"))
@@ -16,8 +16,8 @@ abstract class Router:
     val decoded = components.map(c => URLDecoder.decode(c, "UTF-8")).toList
     page(decoded)
 
-object DefaultRouter extends Router:
-  def path(page: Page): Seq[String] = page match
+class DefaultRouter extends Router:
+  override def path(page: Page): List[String] = page match
     case HomePage =>
       List("")
     case InstanceCreationPage(appId) =>
@@ -31,7 +31,7 @@ object DefaultRouter extends Router:
     case _ =>
       throw IllegalArgumentException(f"No path for $this!")
 
-  def page(path: List[String]): Page = path match
+  override def page(path: List[String]): Page = path match
     case List("") =>
       HomePage
     case List("app", appId) =>
diff --git a/js/src/main/scala/cs214/webapp/client/WebClient.scala b/js/src/main/scala/cs214/webapp/client/WebClient.scala
index 6ec1ee2..2155794 100644
--- a/js/src/main/scala/cs214/webapp/client/WebClient.scala
+++ b/js/src/main/scala/cs214/webapp/client/WebClient.scala
@@ -8,7 +8,7 @@ import org.scalajs.dom
 object WebClient:
   private val appLibrary: mutable.Map[AppId, Seq[ClientApp]] = mutable.Map.empty
 
-  private var router: Router = DefaultRouter
+  private var router: Router = DefaultRouter()
   def setRouter(router: Router): Unit = this.router = router
   def route(url: String): Page = router.route(url)
   def url(page: Page): String = router.url(page)
diff --git a/shared/src/main/scala/cs214/webapp/Common.scala b/shared/src/main/scala/cs214/webapp/Common.scala
index abf60db..c494a49 100644
--- a/shared/src/main/scala/cs214/webapp/Common.scala
+++ b/shared/src/main/scala/cs214/webapp/Common.scala
@@ -8,6 +8,9 @@ object Config:
   On a LAN, the server includes its own local IP in `appInfo` responses. */
   val PUBLIC_INSTANCE = sys.env.getOrElse("WEBAPPLIB_PUBLIC_INSTANCE", "") == "1"
 
+  /** Title shown in website banner **/
+  val BANNER = sys.env.getOrElse("WEBAPPLIB_BANNER", "ScalApp")
+
 /** Unique identifier for an app */
 type AppId = String
 
-- 
GitLab