소스 검색

Migrated from Scala.Rx to Laminar and upgraded version to Scala 2.13.

Includes incorperating Waypoint routing system.

Moved `client` to `webClient`.
Thomas Flucke 2 년 전
부모
커밋
717311f809
77개의 변경된 파일2754개의 추가작업 그리고 2060개의 파일을 삭제
  1. 1 0
      .gitignore
  2. 33 13
      build.sbt
  3. 0 42
      client/src/main/scala/com/weEat/Main.scala
  4. 0 179
      client/src/main/scala/com/weEat/OAuthManager.scala
  5. 0 11
      client/src/main/scala/com/weEat/models/Nutrient.scala
  6. 0 5
      client/src/main/scala/com/weEat/modules/Module.scala
  7. 0 31
      client/src/main/scala/com/weEat/modules/Navbar.scala
  8. 0 305
      client/src/main/scala/com/weEat/modules/NutritionPane.scala
  9. 0 105
      client/src/main/scala/com/weEat/modules/Overlay.scala
  10. 0 62
      client/src/main/scala/com/weEat/modules/PageSelect.scala
  11. 0 42
      client/src/main/scala/com/weEat/modules/PaginatedTable.scala
  12. 0 57
      client/src/main/scala/com/weEat/modules/SearchBar.scala
  13. 0 23
      client/src/main/scala/com/weEat/modules/Select.scala
  14. 0 36
      client/src/main/scala/com/weEat/modules/TypeaheadRx.scala
  15. 0 286
      client/src/main/scala/com/weEat/modules/USDAEditor.scala
  16. 0 234
      client/src/main/scala/com/weEat/util/MHtmlHelpers.scala
  17. 0 16
      client/src/main/scala/com/weEat/util/TimeoutHelper.scala
  18. 0 29
      client/src/main/scala/com/weEat/views/FoodSearch.scala
  19. 0 320
      client/src/main/scala/com/weEat/views/RecipeEdit.scala
  20. 0 86
      client/src/main/scala/com/weEat/views/UsdaImporter.scala
  21. 0 31
      client/src/main/scala/com/weEat/views/UserManage.scala
  22. 0 49
      client/src/main/scala/com/weEat/views/View.scala
  23. 1 1
      fdc/js/src/main/scala/gov/usda/nal/fdc/DateHelper.scala
  24. 5 4
      fdc/shared/src/main/scala/gov/usda/nal/fdc/controllers/FoodController.scala
  25. 1 4
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodItem.scala
  26. 0 1
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodSearchCriteria.scala
  27. 0 2
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/InputFood.scala
  28. 1 1
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SearchResultFood.scala
  29. 1 1
      project/build.properties
  30. 3 3
      project/plugins.sbt
  31. 3 6
      server/app/com/weEat/controllers/FoodController.scala
  32. 1 7
      server/app/com/weEat/controllers/USDAController.scala
  33. 3 7
      server/app/com/weEat/controllers/UserController.scala
  34. 3 7
      server/app/com/weEat/controllers/ViewController.scala
  35. 4 4
      server/app/com/weEat/migrations/InitDb.scala
  36. 0 2
      server/app/com/weEat/migrations/RestoreFromFile.scala
  37. 1 3
      server/app/com/weEat/migrations/SeedNutrition.scala
  38. 4 1
      server/app/com/weEat/models/User.scala
  39. 7 4
      server/app/com/weEat/services/MongoDBService.scala
  40. 3 7
      server/app/com/weEat/services/OAuth2Service.scala
  41. 1 1
      server/app/views/main.scala.html
  42. 4 1
      server/conf/application.conf
  43. 3 3
      server/conf/routes
  44. 36 0
      shared/js/src/main/scala/com/weEat/shared/SecureStorage.scala
  45. 17 0
      shared/js/src/main/scala/com/weEat/shared/package.scala
  46. 35 0
      shared/jvm/src/main/scala/com/weEat/shared/SecureStorage.scala
  47. 11 0
      shared/jvm/src/main/scala/com/weEat/shared/package.scala
  48. 114 0
      shared/shared/src/main/scala/com/weEat/shared/OAuthManager.scala
  49. 22 20
      shared/shared/src/main/scala/com/weEat/shared/models/FoodNode.scala
  50. 1 2
      shared/shared/src/main/scala/com/weEat/shared/models/GrantRequest.scala
  51. 9 4
      shared/shared/src/main/scala/com/weEat/shared/models/MeasureUnit.scala
  52. 1 1
      shared/shared/src/main/scala/com/weEat/shared/models/User.scala
  53. 63 0
      webClient/src/main/scala/com/weEat/Main.scala
  54. 40 0
      webClient/src/main/scala/com/weEat/models/LoginVar.scala
  55. 11 0
      webClient/src/main/scala/com/weEat/models/Nutrient.scala
  56. 75 0
      webClient/src/main/scala/com/weEat/models/UserRegistrationVar.scala
  57. 88 0
      webClient/src/main/scala/com/weEat/models/VarRepresenationOf.scala
  58. 7 0
      webClient/src/main/scala/com/weEat/modules/Module.scala
  59. 27 0
      webClient/src/main/scala/com/weEat/modules/Navbar.scala
  60. 362 0
      webClient/src/main/scala/com/weEat/modules/NutritionPane.scala
  61. 147 0
      webClient/src/main/scala/com/weEat/modules/Overlay.scala
  62. 70 0
      webClient/src/main/scala/com/weEat/modules/PageSelect.scala
  63. 32 0
      webClient/src/main/scala/com/weEat/modules/PaginatedTable.scala
  64. 0 0
      webClient/src/main/scala/com/weEat/modules/README.md
  65. 42 0
      webClient/src/main/scala/com/weEat/modules/SearchBar.scala
  66. 29 0
      webClient/src/main/scala/com/weEat/modules/Select.scala
  67. 41 0
      webClient/src/main/scala/com/weEat/modules/TypeaheadRx.scala
  68. 325 0
      webClient/src/main/scala/com/weEat/modules/USDAEditor.scala
  69. 0 0
      webClient/src/main/scala/com/weEat/util/Cookie.scala
  70. 1 1
      webClient/src/main/scala/com/weEat/util/Storage.scala
  71. 76 0
      webClient/src/main/scala/com/weEat/views/FoodSearch.scala
  72. 0 0
      webClient/src/main/scala/com/weEat/views/README.md
  73. 383 0
      webClient/src/main/scala/com/weEat/views/RecipeEdit.scala
  74. 370 0
      webClient/src/main/scala/com/weEat/views/RecipeView.scala
  75. 137 0
      webClient/src/main/scala/com/weEat/views/UsdaImporter.scala
  76. 17 0
      webClient/src/main/scala/com/weEat/views/UserManage.scala
  77. 82 0
      webClient/src/main/scala/com/weEat/views/View.scala

+ 1 - 0
.gitignore

@@ -1,2 +1,3 @@
 target
 *.log
+.bsp/

+ 33 - 13
build.sbt

@@ -3,16 +3,21 @@ def commonSettings = Seq(
   version := "0.1.0",
   scalacOptions ++= Seq(
     "-deprecation",
-    "-unchecked"
+    "-unchecked",
+    "-feature",
+    "-Xlint:-unused,_",
+    "-Ywarn-unused:imports",
+    "-Ywarn-macros:after",
   )
 )
+
 lazy val server: Project = (project in file("server"))
   .settings(commonSettings)
   .settings(
     scalaJSProjects := Seq(client),
-    pipelineStages in Assets := Seq(scalaJSPipeline),
-    compile in Compile := ((compile in Compile) dependsOn scalaJSPipeline).value,
-    javaOptions in Test += "-Dconfig.file=conf/testing.conf",
+    Assets / pipelineStages := Seq(scalaJSPipeline),
+    Compile / compile := ((Compile / compile) dependsOn scalaJSPipeline).value,
+    Test / javaOptions += "-Dconfig.file=conf/testing.conf",
     libraryDependencies ++= Seq(
       guice,
       "codes.reactive" %% "scala-time" % "0.4.2",
@@ -29,6 +34,8 @@ lazy val server: Project = (project in file("server"))
       "org.webjars.npm" % "sortablejs" % "1.13.0",
       // Mogno + ORM
       "org.mongodb.scala" %% "mongo-scala-driver" % "4.1.0",
+      //"com.scalableminds" %% "mongev" % "0.5.0",
+      //"com.scalableminds" %% "mongev"             % "0.5.0",
       // Testing
       specs2 % Test,
       "org.scalatestplus.play" %% "scalatestplus-play" % "5.0.0" % Test
@@ -39,16 +46,22 @@ lazy val server: Project = (project in file("server"))
   .dependsOn(fdcJvm)
 
 import com.tflucke.webroutes.endpoints.PlayEndpointFile
+import org.scalajs.linker.interface.ModuleInitializer
 
-lazy val client = (project in file("client"))
+lazy val client = (project in file("webClient"))
   .settings(commonSettings)
   .settings(
+    //scalaJSMainModuleInitializer := Some(ModuleInitializer.mainMethod("com.weEat.Main", "main").withModuleID("we-eat")),
     scalaJSUseMainModuleInitializer := true,
-    libraryDependencies += "org.querki"  %%% "jquery-facade"  % "2.0",
-    libraryDependencies += "in.nvilla"   %%% "monadic-html" % "0.4.1",
-    libraryDependencies += "in.nvilla"   %%% "monadic-rx-cats" % "0.4.1",
-    libraryDependencies += "com.tflucke" %%% "typeahead-scala" % "0.2.1",
-    libraryDependencies += "com.tflucke" %%% "sortablejs-facade" % "1.13.0",
+    libraryDependencies ++= Seq(
+      "net.exoego"   %%% "scalajs-jquery3"             % "2.2.0",
+      "com.raquo"    %%% "laminar"                     % "0.14.2",
+      "io.laminext"  %%% "core"                        % "0.16.2",
+      "com.tflucke"  %%% "typeahead-scala"             % "0.2.2",
+      "com.tflucke"  %%% "sortablejs-facade"           % "1.13.0",
+      "org.scala-js" %%% "scala-js-macrotask-executor" % "1.0.0",
+      "com.raquo"    %%% "waypoint"                    % "7.0.0",
+    ),
     scalacOptions ++= {
       import Ordering.Implicits._
       if (VersionNumber(scalaVersion.value).numbers >= Seq(2L, 13L))
@@ -71,22 +84,29 @@ lazy val shared = crossProject(JSPlatform, JVMPlatform)
   )
   .enablePlugins(RestRPC)
   .jsConfigure(_ enablePlugins ScalaJSWeb)
+
 lazy val sharedJvm = shared.jvm.dependsOn(fdcJvm).settings(
   libraryDependencies += "org.mongodb.scala" %% "mongo-scala-bson" % "4.1.0"
 )
-lazy val sharedJs = shared.js.dependsOn(fdcJs)
+lazy val sharedJs = shared.js.dependsOn(fdcJs).settings(
+  libraryDependencies ++= Seq(
+    "org.scala-js" %%% "scala-js-macrotask-executor" % "1.0.0",
+    "io.github.cquiroz" %%% "scala-java-time"        % "2.2.2",
+    "io.github.cquiroz" %%% "scala-java-time-tzdb"   % "2.2.2",
+  )
+)
 
 lazy val fdc = crossProject(JSPlatform, JVMPlatform)
   .in(file("fdc"))
   .settings(commonSettings)
   .settings(
     libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2",
-    libraryDependencies += "com.tflucke"       %%% "rest-rpc"  % "0.3.1"
+    libraryDependencies += "com.tflucke"       %%% "rest-rpc"  % "0.3.2"
   )
 lazy val fdcJs = fdc.js
 lazy val fdcJvm = fdc.jvm
 
 // loads the server project at sbt startup
-onLoad in Global := (onLoad in Global).value.andThen(
+Global / onLoad := (Global / onLoad).value.andThen(
   state => "project server" :: state
 )

+ 0 - 42
client/src/main/scala/com/weEat/Main.scala

@@ -1,42 +0,0 @@
-package com.weEat
-
-import com.tflucke.webroutes.Headers
-import com.weEat.view.View
-import com.weEat.modules.Navbar
-import org.querki.jquery.{JQueryStatic => $}
-import org.scalajs.dom.{document,window}
-import org.scalajs.dom.raw.HTMLInputElement
-import scala.concurrent.ExecutionContext.Implicits.global
-
-object Main {
-  implicit def headers = Headers(Seq(
-    csrfHeader,
-    OAuthManager.authHeader
-  ).flatten.toMap)
-
-  /* Get the CSRF token embedded in the HTML page.  This token will implicitly
-   * be passed to Rest RPC.
-   */
-  val csrfSelector = "input[name=\"csrfToken\"]"
-  def csrfHeader = Option(document.querySelector(csrfSelector)).map({ x =>
-    ("Csrf-Token" -> x.asInstanceOf[HTMLInputElement].value)
-  })
-
-  def main(args: Array[String]): Unit = {
-    import org.scalajs.dom.experimental.URLSearchParams
-
-    OAuthManager.refreshToken()
-
-    $("#btn-login").click(OAuthManager.promptLogin _)
-    $("#btn-signup").click(OAuthManager.promptSignup _)
-    $("#btn-logout").click(OAuthManager.logout _)
-
-    val navbar = Navbar(View.authedIndex)
-    mhtml.mount(
-      document.getElementById("navbarNav"),
-      navbar.render
-    )
-    val params = new URLSearchParams(window.location.search)
-    navbar.renderView(View.fromTag(params.get("t")))
-  }
-}

+ 0 - 179
client/src/main/scala/com/weEat/OAuthManager.scala

@@ -1,179 +0,0 @@
-package com.weEat
-
-import com.tflucke.webroutes.{HTTPException,TimeoutException}
-import com.weEat.controllers.UserController
-import com.weEat.shared.models._
-import com.weEat.util.SessionStorage
-import org.querki.jquery.{JQueryEventObject,JQuery,JQueryXHR,JQueryStatic => $}
-import org.scalajs.dom.document
-import org.scalajs.dom.raw.Element
-import play.api.libs.json.Json
-import scala.concurrent.ExecutionContext.Implicits.global
-import scala.concurrent.Future
-import scala.scalajs.js
-import scala.scalajs.js.timers
-import scala.util.{Try,Success,Failure}
-import mhtml.{Rx,Var}
-
-object OAuthManager {
-  import Main.headers
-
-  private val _scope = Var(Set.empty[String])
-
-  def authHeader = SessionStorage.get("access-token").map({ token =>
-    ("Authorization" -> token)
-  })
-
-  def isAuthedFor(perms: String) = _scope.map(_.contains(perms))
-
-  def promptLogin() =
-    $("body").append(overlayWindow("/assets/views/login.html", login))
-
-  def promptSignup() =
-    $("body").append(overlayWindow("/assets/views/register.html", signup))
-
-  def refreshToken(): Future[Unit] = {
-    SessionStorage.remove("access-token")
-    SessionStorage.get("username").map({user =>
-      SessionStorage.get("refresh-token").map({refresh =>
-        UserController.accessToken()(RefreshRequest(user, refresh))
-          .map(loginComplete(user)).recover({
-            case _ => clear()
-          }).map(_ => ())
-      })
-    }).flatten
-      .getOrElse(Future.failed(new IllegalStateException("No token present")))
-  }
-
-  def loginComplete(user: String)(auth: UserAuthorization) = {
-    SessionStorage.set("access-token", s"${auth.tokenType} ${auth.accessToken}")
-    SessionStorage.set("username", user)
-    SessionStorage.set("refresh-token", auth.refreshToken)
-    _scope := auth.scope
-    timers.setTimeout(auth.expiresIn)(refreshToken)
-    $("#login-btns").hide()
-    $("#logout-btns").show()
-  }
-
-  def signup(div: JQuery): Future[Any] = {
-    import java.util.InputMismatchException
-
-    val email = div.find("#email").value().toString
-    val password = div.find("#password").value().toString
-    (if (!password.equals(div.find("#password2").value().toString))
-      Future.failed(new InputMismatchException("Passwords do not match."))
-    else
-      UserController.registerUser()(UserRegistration(
-        div.find("#fname").value().toString,
-        div.find("#lname").value().toString,
-        email,
-        password
-      )) map(loginComplete(email))) andThen({
-        case Failure(e: InputMismatchException) => showError(div, e.getMessage)
-        case Failure(e: HTTPException) => showError(div, e.responseText)
-        case Failure(e: TimeoutException) => showError(div, e.getMessage)
-      })
-  }
-
-  @js.native
-  @js.annotation.JSGlobal("btoa")
-  def base64Encode(str: String): String = js.native
-
-  def showError(div: JQuery, msg: String) = div.find(".alert-danger").html(
-    s"<strong>Error:</strong> $msg"
-  ).show()
-
-  def parseString[T](implicit reader: play.api.libs.json.Reads[T]) = {
-    str: String => Json.parse(str).as[T]
-  }
-
-  def login(div: JQuery): Future[Any] = {
-    val email = div.find("#email").value().toString
-    implicit var headers = Main.headers +
-      ("Authorization" -> ("Basic " + base64Encode(
-        "%s:%s".format(email, div.find("#password").value())
-      )))
-    UserController.accessToken()(PasswordRequest()).andThen({
-      case Success(auth) => loginComplete(email)(auth)
-      case Failure(error: HTTPException) => Try(
-        error.responseObject(parseString[GrantError])
-      ) match {
-        case Success(gError) => showError(div, gError.errorDescription)
-        case Failure(_) => showError(div, error.getMessage)
-      }
-      case Failure(error: TimeoutException) => showError(div, error.getMessage)
-    })
-  }
-
-  def logout(): Future[Unit] = SessionStorage.get("username").map({user =>
-    SessionStorage.get("refresh-token").map({ refresh: String =>
-      UserController.revokeAccessToken()(RefreshRequest(user, refresh))
-    })
-  }).flatten.getOrElse(Future.failed(
-    new IllegalStateException("No login information.")
-  )).map({ _ =>
-    clear()
-    document.location.reload(false)
-  })
-
-  def clear() = {
-    SessionStorage.remove("username").remove("refresh-token")
-    _scope := Set.empty[String]
-  }
-
-  def shadeWindow(cancelFn: Option[(() => Unit)]) = {
-    val shadeDiv = $("<div>").css(js.Dictionary[js.Any](
-      "z-index" -> 99,
-      "background-color" -> "rgba(0, 0, 0, 0.5)",
-      "position" -> "fixed",
-      "top" -> 0,
-      "bottom" -> 0,
-      "left" -> 0,
-      "right" -> 0
-    ))
-    shadeDiv.click((event: JQueryEventObject) => {
-      if (event.target == shadeDiv(0))
-      {
-        cancelFn match {
-          case Some(fn) => fn()
-          case None =>
-        }
-        $(event.target).detach()
-      }
-    })
-  }
-
-  def overlayWindow[T](
-    contentUrl: String,
-    submitFn: (JQuery) => Future[T],
-    cancelFn: Option[() => Unit] = None
-  ) = {
-    val promptDiv = $("<div>").css(js.Dictionary[js.Any](
-      "margin" -> "auto",
-      "padding" -> "1em",
-      "background-color" -> "#ffffff",
-      "position" -> "relative",
-      "border-radius" -> "1em",
-      "top" -> "50%",
-      "transform" -> "translateY(-50%)"
-    )).addClass("w-50")
-    val shadeDiv = shadeWindow(cancelFn).append(promptDiv)
-    promptDiv.load(contentUrl, "",
-      (elm: Element, resp: String, status: String, xhr: JQueryXHR) => {
-        $(elm).find("*[data-cb='cancel']").click((event: JQueryEventObject) => {
-          cancelFn.map({fn => fn()})
-          shadeDiv.detach()
-        })
-        val div =  $(elm).find("*[data-cb='success']").click(
-          (event: JQueryEventObject) => {
-            submitFn(promptDiv) onComplete {
-              case Success(_) => shadeDiv.detach()
-              case Failure(err) => System.err.println(err)
-            }
-          }
-        )
-      }
-    )
-    shadeDiv.append(promptDiv)
-  }
-}

+ 0 - 11
client/src/main/scala/com/weEat/models/Nutrient.scala

@@ -1,11 +0,0 @@
-package com.weEat.models
-
-import com.weEat.controllers.USDAController
-import mhtml.future.syntax._
-
-object Nutrient {
-
-  implicit val ctx = scala.concurrent.ExecutionContext.global
-
-  lazy val nutrients = USDAController.getNutrients()().toRx
-}

+ 0 - 5
client/src/main/scala/com/weEat/modules/Module.scala

@@ -1,5 +0,0 @@
-package com.weEat.modules
-
-trait Module {
-  val render: scala.xml.Node
-}

+ 0 - 31
client/src/main/scala/com/weEat/modules/Navbar.scala

@@ -1,31 +0,0 @@
-package com.weEat.modules
-
-import mhtml.{Rx,Cancelable}
-import com.weEat.view.View
-
-case class Navbar(nav: Rx[Seq[View]]) extends Module {
-
-  private var lastCancelable: Option[Cancelable] = None
-
-  def renderView(view: View) = {
-    lastCancelable.foreach(_.cancel)
-    lastCancelable = Some(view.present())
-    ()
-  }
-
-  val render = {
-    <ul class="navbar-nav mr-auto mt-2 mt-lg-0">
-    {
-      nav.map({
-        for (view <- _) yield {
-          <li class={if (false) "nav-item active" else "nav-item"}>
-            <a class="nav-link" href="#" onclick={() =>
-              renderView(view)
-            }>{view.title}</a>
-          </li>
-        }
-      })
-    }
-    </ul>
-  }
-}

+ 0 - 305
client/src/main/scala/com/weEat/modules/NutritionPane.scala

@@ -1,305 +0,0 @@
-package com.weEat.modules
-
-import com.weEat.modules._
-import com.weEat.shared.models.{FoodNode, UnitType}
-import mhtml.Rx
-import scala.xml.Elem
-import scala.concurrent.ExecutionContext
-import org.scalajs.dom.raw.HTMLInputElement
-import scala.scalajs.js.timers.setTimeout
-import scala.concurrent.duration._
-import scala.language.postfixOps
-import com.weEat.util.MHtmlHelpers._
-import scala.concurrent.Future
-import scala.util.{Success,Failure}
-import mhtml.future.syntax._
-import scala.scalajs.js.`|`
-
-case class NutritionPane(val food: Rx[FoodNode])
-    extends Module {
-  implicit val ctx = scala.concurrent.ExecutionContext.global
-
-  private val _daily = Map(
-    "204" -> 78f,   // Total Fat
-    "606" -> 20f,   // Saturated Fat
-    "601" -> 300f,  // Cholesterol
-    "605" -> 2f,    // Trans fats
-    "307" -> 2300f, // Sodium
-    "205" -> 275f,  // Total Carbohydrates
-    "291" -> 28f,   // Dietary Fiber
-    "320" -> 800f,  // Vitamin A (900 M, 700 F)
-    "415" -> (1.15f + 1.2f + 15f + 1.3f + 2.4f + 5f), // Vitamin B
-    "401" -> 88f,   // Vitamin C (90 M, 75 F)
-    "324" -> 15f,   // Vitamin D
-    "301" -> 1300f, // Calcium
-    "303" -> 13f,   // Iron (8 M, 18 F)
-    "306" -> 4700f, // Potassium
-    "432" -> 400f   // Folate
-  )
-  private val _caloriesInFat = 8.84f
-
-  private def _show(decimals: Int)(getNutrient: FoodNode => Future[Float]) =
-    food.map(getNutrient(_).toRx).flatten.map({
-      case Some(Success(v)) => s"%.${decimals}f".format(v)
-      case Some(Failure(e)) =>
-        println(e)
-        "err"
-      case None => ""
-    })
-
-  private def _showDaily(nutrient: String) =
-    (_show(0) { _.nutrient(nutrient).map(_ / _daily(nutrient) * 100.0f) })
-      .map(_ + "%")
-
-  private def _showDailyLine(
-    title: Elem,
-    dec: Int,
-    unit: String,
-    nutrient: String
-  ): Elem =
-    <div class="line">
-      <div class="nutrLabel">Total {title}
-        <div class="weight">{ _show(dec) { _.nutrient(nutrient) } }{ unit }</div>
-      </div>
-      <div class="dv">
-        { _showDaily(nutrient) }
-      </div>
-    </div>
-
-  private def _showDailyLine(
-    title: String,
-    dec: Int,
-    unit: String,
-    nutrient: String
-  ): Elem = _showDailyLine( <span>{title}</span>, dec, unit, nutrient)
-
-  val render = <div>
-  <style type="text/css">
-    {"""#nutritionfacts { 
-        background-color:white; 
-        border:1px solid black; 
-        padding:3px;
-        padding-right:5px;
-        width:244px; 
-    }
-    #nutritionfacts td { 
-        color:black; 
-        font-family:'Arial Black','Helvetica Bold',sans-serif; 
-        font-size:8pt; 
-        padding:0; 
-    }
-    #nutritionfacts td.header { 
-        font-family:'Arial Black','Helvetica Bold',sans-serif; 
-        font-size:28px; 
-        white-space:nowrap; 
-    }        
-    #nutritionfacts div.nutrLabel { 
-        float:left; 
-        font-family:'Arial Black','Helvetica Bold',sans-serif; 
-    }
-    #nutritionfacts div.serving { 
-        font-family:Arial,Helvetica,sans-serif; 
-        font-size:8pt; 
-        text-align:center; 
-    }
-    #nutritionfacts div.weight { 
-        display:inline; 
-        font-family:Arial,Helvetica,sans-serif; 
-        padding-left:1px; 
-    }
-    #nutritionfacts div.dv { 
-        display:inline; 
-        float:right; 
-        font-family:'Arial Black','Helvetica Bold',sans-serif; 
-    }
-    #nutritionfacts table.vitamins td {  
-        font-family:Arial,Helvetica,sans-serif; 
-        white-space:nowrap; 
-        width:33%; 
-    }
-    #nutritionfacts div.line { 
-        border-top:1px solid black; 
-    }
-    #nutritionfacts div.nutrLabellight { 
-        float:left; 
-        font-family:Arial,Helvetica,sans-serif; 
-    }
-    #nutritionfacts .highlighted {
-        border:1px dotted grey;
-        padding:2px;
-    }"""}
-  </style>
-
-  <div id="nutritionfacts">
-    <table style="width: 100%; border-spacing: 0;" cellpadding="0">
-      <tbody>
-        <tr>
-          <td style="text-align: center;" class="header">Nutrition Facts</td>
-        </tr>
-        <tr>
-          <td>
-            <div class="serving">
-              Per <span class="highlighted">
-                {food.map({f => f.defaultUnitType.standardQuanity})}
-                {food.map({f => f.defaultUnitType.defaultUnit.abr})}
-              </span>
-              Serving Size
-            </div>
-          </td>
-        </tr>
-        <tr style="height: 7px">
-          <td style="background-color: #000000;"></td>
-        </tr>
-        <tr>
-          <td style="font-size: 7pt">
-            <div class="line">Amount Per Serving</div>
-          </td>
-        </tr>
-        <tr>
-          <td>
-            <div class="line">
-              <div class="nutrLabel">
-                Calories
-                <div class="weight">{ _show(0) { _.nutrient("208") } }</div>
-              </div>
-              <div style="padding-top: 1px; float: right;" class="nutrLabellight">
-                Calories from Fat
-                <div class="weight">
-                  { _show(0) { _.nutrient("204").map(_*_caloriesInFat) } }
-                </div>
-              </div>
-            </div>
-          </td>
-        </tr>
-        <tr>
-          <td>
-            <div class="line">
-              <div class="dvnutrLabel">% Daily Value<sup>*</sup></div>
-            </div>
-          </td>
-        </tr>
-        <tr>
-          <td>
-            { _showDailyLine("Fat", 0, "g", "204") }
-          </td>
-        </tr>
-        <tr>
-          <td class="indent">
-            { _showDailyLine("Saturated Fat", 1, "g", "606") }
-          </td>
-        </tr>
-        <tr>
-          <td class="indent">
-            { _showDailyLine(<span><i>Trans</i> Fat</span>, 1, "g", "605") }
-          </td>
-        </tr>
-        <tr>
-          <td>
-            { _showDailyLine("Cholesterol", 1, "mg", "601") }
-          </td>
-        </tr>
-        <tr>
-          <td>
-            { _showDailyLine("Sodium", 1, "mg", "307") }
-          </td>
-        </tr>
-        <tr>
-          <td>
-            { _showDailyLine("Total Carbohydrates", 1, "g", "205") }
-          </td>
-        </tr>
-        <tr>
-          <td class="indent">
-            { _showDailyLine("Dietary Fiber", 1, "g", "291") }
-          </td>
-        </tr>
-        <tr>
-          <td class="indent">
-            <div class="line">
-              <div class="nutrLabellight">
-                Sugars
-                <div class="weight">{ _show(1) { _.nutrient("269") } }g</div>
-              </div>
-            </div>
-          </td>
-        </tr>
-        <tr>
-          <td>
-            <div class="line">
-              <div class="nutrLabel">Protein
-                <div class="weight">{ _show(1) { _.nutrient("203") } }g</div>
-              </div>
-          </div></td>
-        </tr>
-        <tr style="height: 7px">
-          <td style="background-color: #000000;"></td>
-        </tr>
-        <tr>
-          <td>
-            <table style="border-style: none; border-spacing: 0; width: 100%;"
-                   cellpadding="0" class="vitamins">
-              <tbody>
-                <tr>
-                  <td>
-                    Vitamin A {"  "}
-                    { _showDaily("320") }
-                  </td>
-                  <td style="text-align: center;">•</td>
-                  <td style="text-align: right;">
-                    Calcium {"  "}
-                    { _showDaily("301") }
-                  </td>
-                </tr>
-                <tr>
-                  <td>
-                    Vitamin B {"  "}
-                    { _showDaily("415") }
-                  </td>
-                  <td style="text-align: center;">•</td>
-                  <td style="text-align: right;">
-                    Iron {"  "}
-                    { _showDaily("303") }
-                  </td>
-                </tr>
-                <tr>
-                  <td>
-                    Vitamin C {"  "}
-                    { _showDaily("401") }
-                  </td>
-                  <td style="text-align: center;">•</td>
-                  <td style="text-align: right;">
-                    Potassium {"  "}
-                    { _showDaily("306") }
-                  </td>
-                </tr>
-                <tr>
-                  <td>
-                    Vitamin D {"  "}
-                    { _showDaily("324") }
-                  </td>
-                  <td style="text-align: center;">•</td>
-                  <td style="text-align: right;">
-                    Folate {"  "}
-                    { _showDaily("432") }
-                  </td>
-                </tr>                        
-            </tbody></table>
-          </td>
-        </tr>
-        <tr>
-          <td>
-            <div class="line">
-              <div class="nutrLabellight">
-                * Based on a regular 2000 calorie diet<br />
-                <br />
-                <i>Nutritional details are an estimate and should only be used as a
-                  guide for approximation.</i>
-              </div>
-            </div>
-          </td>
-        </tr>
-      </tbody>
-    </table>
-  </div>
-</div>
-}

+ 0 - 105
client/src/main/scala/com/weEat/modules/Overlay.scala

@@ -1,105 +0,0 @@
-package com.weEat.modules
-
-import mhtml.{mount,Rx}
-import org.scalajs.dom.document
-import org.scalajs.dom.raw.{MouseEvent,HTMLDivElement}
-import scala.xml.Node
-
-case class Overlay(
-  content: Rx[Node],
-  cancelFn: Option[() => Unit] = None
-) extends Module {
-  private val backgroundShade = {
-    val div = document.createElement("div").asInstanceOf[HTMLDivElement]
-    div.addEventListener("click", { e: MouseEvent =>
-      if (e.target == div) {
-        cancelFn.map({fn => fn()})
-        selfDestruct()
-      }
-    })
-    div.style.zIndex = "99"
-    div.style.backgroundColor = "rgba(0, 0, 0, 0.5)"
-    div.style.position = "fixed"
-    div.style.textAlign = "center"
-    div.style.top = "0"
-    div.style.left = "0"
-    div.style.right = "0"
-    div.style.bottom = "0"
-    div
-  }
-  
-  val render = {
-    <div class="p-4 position-relative" style="background-color: #ffffff; border-radius: 1em; top: 50%; transform: translateY(-50%); max-height: 80%; overflow-y: auto; display: inline-block; vertical-align: middle; max-width: 50%;">
-      {content}
-    </div>
-  }
-
-  def selfDestruct(): Unit =
-    backgroundShade.parentNode.removeChild(backgroundShade)
-
-  document.body.appendChild(backgroundShade)
-  mount(backgroundShade, render)
-}
-
-object Overlay {
-  import scala.concurrent.{Future,ExecutionContext}
-
-  implicit val ec = scala.concurrent.ExecutionContext.global
-
-  def confirm(
-    content: Rx[Node],
-    cancelFn: Option[() => Unit] = None
-  )(confirmFn: () => Unit) = {
-    val overlayPromise = scala.concurrent.Promise[Overlay]()
-    val overlayFut = overlayPromise.future
-    val wrappedContent = <div>
-      {content}
-      <div class="pt-2 pb-2" style="text-align: right;">
-        <button type="button" class="btn btn-light" onclick={ e: MouseEvent =>
-          cancelFn.map({fn => fn()})
-          overlayFut.map(_.selfDestruct())
-          ()
-        }>Cancel</button>
-        <button type="button" class="btn btn-success" onclick={ e: MouseEvent =>
-          confirmFn()
-          overlayFut.map(_.selfDestruct())
-          ()
-        }>Submit</button>
-      </div>
-    </div>
-    overlayPromise.success(Overlay(Rx(wrappedContent), cancelFn))
-  }
-
-  def loading[T](fut: Future[T], cancelFn: Option[() => Unit] = None)
-  (implicit ctx: ExecutionContext) = {
-    val overlay = Overlay(Rx(<span>Loading...</span>), cancelFn)
-    fut.andThen({
-      case _ => overlay.selfDestruct()
-    })
-    overlay
-  }
-
-  def progress[T](
-    content: Rx[Node],
-    cancelFn: Option[() => Unit] = None
-  )(nextFn: () => Unit) = {
-    val overlayPromise = scala.concurrent.Promise[Overlay]()
-    val overlayFut = overlayPromise.future
-    val wrappedContent = <div>
-      {content}
-      <div class="pt-2 pb-2" style="text-align: right;">
-        <button type="button" class="btn btn-light" onclick={ e: MouseEvent =>
-          cancelFn.map({fn => fn()})
-          overlayFut.map(_.selfDestruct())
-          ()
-        }>Cancel</button>
-        <button type="button" class="btn btn-light" onclick={ e: MouseEvent =>
-          nextFn()
-          overlayFut.map(_.selfDestruct())
-          ()
-        }>Next</button>
-      </div>
-    </div>
-    overlayPromise.success(Overlay(Rx(wrappedContent), cancelFn))
-  }
-}

+ 0 - 62
client/src/main/scala/com/weEat/modules/PageSelect.scala

@@ -1,62 +0,0 @@
-package com.weEat.modules
-
-import mhtml.{Rx,Var}
-
-case class PageSelect(
-  numPages: Rx[Int],
-  revealedRadius: Rx[Int] = Rx(3)
-) extends Module {
-  private val _page = Var[Int](1)
-
-  // TODO: Add buttons for First/Last page
-
-  // TODO: Allow the user to click on `...` to temporarily expand the upper or
-  // lower reveal boundary
-  private val _minRevealed = _page.zip(revealedRadius).map({
-    case (p, r) => 1.max(p - r)
-  })
-  private val _maxRevealed = _page.zip(revealedRadius).zip(numPages).map({
-    case ((p, r), n) => n.min(p + r)
-  })
-
-  val page = _page.zip(numPages).map({ case (idx, pages) => idx.min(pages) - 1 })
-
-  private def idxToString(targIdx: Int, str: String) =
-    _page.map({page: Int => if (page == targIdx) str else ""})
-
-  val render = {
-    <ul class="pagination">
-      <li class={idxToString(1, " disabled").map("page-item"+_)}>
-        <a class="page-link" href="#" onclick={ () => _page.update(_ - 1)}>
-          <span>{"<"}</span>
-          <span class="sr-only">Previous</span>
-        </a>
-      </li>
-      <li class="page-item disabled" style={_minRevealed.map({ min =>
-        if (min <= 1) "display: none;" else ""
-      })}><a class="page-link" href="#">...</a></li>
-      {
-        _minRevealed.zip(_maxRevealed).map({
-          case (min, max) => for (i <- (min to max)) yield {
-            <li class={idxToString(i, " active").map("page-item"+_)}>
-              <a class="page-link" href="#" onclick={ () => _page.update(_ => i)}>
-                {i.toString}
-              </a>
-            </li>
-          }
-        })
-      }
-      <li class="page-item disabled" style={_maxRevealed.zip(numPages).map({
-        case (max, n) => if (n <= max) "display: none;" else ""
-      })}><a class="page-link" href="#">...</a></li>
-    <li class={numPages.flatMap({targIdx =>
-      _page.map({page: Int => if (page >= targIdx) " disabled" else ""})
-    }).map("page-item"+_)}>
-        <a class="page-link" href="#" onclick={ () => _page.update(_ + 1)}>
-          <span>{">"}</span>
-          <span class="sr-only">Next</span>
-        </a>
-      </li>
-    </ul>
-  }
-}

+ 0 - 42
client/src/main/scala/com/weEat/modules/PaginatedTable.scala

@@ -1,42 +0,0 @@
-package com.weEat.modules
-
-import mhtml.Rx
-import scala.xml.Node
-
-case class PaginatedTable[T](
-  structure: Seq[(String, Short, (T) => Rx[Node])],
-  tblData: Rx[Seq[T]],
-  page: Rx[Int] = Rx(0),
-  pageSize: Rx[Int] = Rx(10)
-) extends Module {
-  val render = {
-    <table class="table table-striped table-hover">
-      <thead>
-        <tr>
-          {
-            for (col <- structure) yield {
-              <th class={s"col-md-${col._2}"}>
-                <span>{col._1}</span>
-              </th>
-            }
-          }
-        </tr>
-      </thead>
-      <tbody>
-        {
-          tblData.zip(page).zip(pageSize).map({ case ((data, idx), size) =>
-            data.slice(idx*size, (idx+1)*size).map({datum =>
-              <tr>
-                {
-                  structure.map({col =>
-                    <td>{col._3(datum)}</td>
-                  })
-                }
-              </tr>
-            })
-          })
-        }
-      </tbody>
-    </table>
-  }
-}

+ 0 - 57
client/src/main/scala/com/weEat/modules/SearchBar.scala

@@ -1,57 +0,0 @@
-package com.weEat.modules
-
-import cats.implicits._
-import cats.Traverse._
-import com.weEat.modules._
-import com.weEat.util.MHtmlHelpers._
-import mhtml.Rx
-import mhtml.future.syntax._
-import mhtml.implicits.cats._
-import scala.concurrent.duration._
-import scala.concurrent.{ExecutionContext,Future,Promise}
-import scala.scalajs.js.timers.{clearTimeout, setTimeout}
-import scala.util.Try
-
-case class SearchBar[T](
-  searchFn: (String) => Future[T],
-  minLength: Int = 3,
-  delay: FiniteDuration = 500 milliseconds
-)(implicit val ec: ExecutionContext = ExecutionContext.global) extends Module {
-
-  // TODO: Refactor these out into utility classes
-  def setTimeoutResult[T](delay: FiniteDuration)(body: => T) = {
-    val promise = Promise[T]()
-    setTimeout(delay) {
-      promise.success(body)
-    }
-    promise.future
-  }
-
-  private val (_render, _inputTerm) =
-    (<input type="text" class="form-control input-sm" />).value()
-  val render = _render
-
-  val searchTerm = _inputTerm.map[Option[String]](Some(_))
-    .keepIf(_.map(_.length >= minLength).getOrElse(false))(None)
-    .dropRepeats.impure.sharing
-
-  // Nested futures give us a hellish type web to unweave
-  val result: Rx[Option[Try[T]]] = searchTerm.flatMap({ term =>
-    term.map({ termAtStart =>
-      setTimeoutResult(delay) {
-        searchTerm.map({ termAfterDelay =>
-          if (Some(termAtStart) == termAfterDelay)
-            searchFn(termAtStart).toRx
-          else
-            Rx(None)
-        }).flatten
-      }.toRx.map(
-        _.map(
-          _.sequence[Rx, Option[Try[T]]].map(
-            _.flatSequence[Option, T]
-          )
-        )
-      ).map(_.flatSequence[Rx, Try[T]]).flatten
-    }).flatSequence[Rx, Try[T]]
-  }).impure.sharing
-}

+ 0 - 23
client/src/main/scala/com/weEat/modules/Select.scala

@@ -1,23 +0,0 @@
-package com.weEat.modules
-
-import com.weEat.util.MHtmlHelpers._
-import mhtml.Rx
-
-case class Select[T](values: Rx[Seq[T]], defaul: Option[T] = None) extends Module {
-  private val (_select, _value) =
-    (<select class="form-control input-sm">
-      {
-        values.map(_.zipWithIndex).map({ _.map({ case (value, idx) =>
-          <option selected={Some(value) == defaul} value={idx.toString}>
-            {value.toString}
-          </option>
-        }) })
-      }
-    </select>).value()
-
-  val value = _value.zip(values).map({ case (idx, v) =>
-    v(idx.toIntOption.getOrElse(0).max(0))
-  })
-
-  val render = <div class="form-group form-group-sm">{_select}</div>
-}

+ 0 - 36
client/src/main/scala/com/weEat/modules/TypeaheadRx.scala

@@ -1,36 +0,0 @@
-package com.weEat.modules
-
-import com.weEat.modules._
-import mhtml.Var
-import com.tflucke.typeahead._
-import scala.concurrent.ExecutionContext
-import org.scalajs.dom.raw.HTMLInputElement
-import scala.scalajs.js.timers.setTimeout
-import scala.concurrent.duration._
-import scala.language.postfixOps
-import com.weEat.util.MHtmlHelpers._
-
-case class TypeaheadRx[T](val datasets: Dataset[T]*)
-(implicit val ec: ExecutionContext = ExecutionContext.global) extends Module {
-
-  private val _query = Var("")
-  private val _result = Var[Option[T]](None)
-
-  val render = <input type="text" class="form-control input-sm" />
-    .afterMount { elm =>
-      // If we try to mount it immediately, the parent won't exist yet.
-      // Wait for the mounting to finish, then wrap in typeahead
-      TypeaheadElement(
-        elm.asInstanceOf[HTMLInputElement]
-      )(datasets)
-      elm.addEventListener("typeahead:selected", { e: CursorEvent[T] =>
-        _result := e.selectable.map(_.data)
-      })
-      elm.addEventListener("typeahead:queryChanged", { e: QueryEvent =>
-        _query := e.query
-      })
-    }
-
-  val query = _query.impure.sharing
-  val value = _result.impure.sharing
-}

+ 0 - 286
client/src/main/scala/com/weEat/modules/USDAEditor.scala

@@ -1,286 +0,0 @@
-package com.weEat.modules
-
-import com.weEat.controllers.USDAController
-import com.weEat.shared.models.UnitType
-import com.weEat.shared.models.UnitType._
-import com.weEat.models.Nutrient
-import scala.util.{Try,Success,Failure}
-import com.weEat.shared.models.IdentifierHelper._
-import com.weEat.shared.models.{USDANode,USDANodeNoId,USDANodeId}
-import gov.usda.nal.fdc.models.{SearchResultFood,FullFoodItem,FoodPortion}
-import com.weEat.shared.models.{Count,MeasureUnit,USDANode}
-import mhtml.Rx
-import mhtml.future.syntax._
-import com.weEat.util.MHtmlHelpers._
-
-case class USDAEditor(
-  usda: USDANode,
-  defaultOverNone: Boolean
-) extends Module {
-  implicit val ctx = scala.concurrent.ExecutionContext.global
-
-  private val _mapPortionMeasure =
-    USDAController.getFood(usda.fdcId)().transform({
-      case Success(full: FullFoodItem) => Success(full.foodPortions.map({ port =>
-        val str = portionStr(port)
-        (port, portionToMeasure(port, str), str)
-      }
-      ))
-      case Success(_) => Success(Nil)
-      case Failure(e) => {
-        Console.err.println(e)
-        Failure(e)
-      }
-    }).toRx
-
-  private val _fdcId = usda.fdcId
-
-  private val (_nameIn, _name) =
-    <input type="text" style="text-align: center;" class="form-control"
-           value={usda.name} />.value()
-
-  private val (_calorieIn, _calorie) =
-    floatInput("calories", Some(usda.calories)).value()
-
-  val noneValue = "none"
-  val customValue = "custom"
-
-  val defaultVolPort = _mapPortionMeasure.map({
-    case Some(Success(seq)) => getDefaultPortion(seq, VOLUME, usda.density)
-    case _ => None
-  })
-  private val (_volumeIn, _volume) = <select class="custom-select">
-    <option selected={defaultVolPort.map(_ == None && usda.density == None)}
-            value={noneValue}>None</option>
-    <option selected={defaultVolPort.map(_ == None && usda.density != None)}
-            value={customValue}>Custom</option>
-    {
-      _mapPortionMeasure.map({
-        case Some(Success(seq)) => seq.filter(_._2.typ == VOLUME)
-        case _ => Nil
-      }).map(_.map({ case (p, m, str) =>
-        <option selected={defaultVolPort.map(_ == Some(p))}
-                value={calcConversion(p, m).toString}>{str}</option>
-      }))
-    }
-  </select>.value()
-
-  val defaultNumPort = _mapPortionMeasure.map({
-    case Some(Success(seq)) => getDefaultPortion(seq, NUMBER, usda.massPerUnit)
-    case _ => None
-  })
-  private val (_countIn, _count) = <select class="custom-select">
-    <option selected={defaultNumPort.map(_ == None && usda.massPerUnit == None)}
-            value={noneValue}>None</option>
-    <option selected={defaultNumPort.map(_ == None && usda.massPerUnit != None)}
-            value={customValue}>Custom</option>
-    {
-      _mapPortionMeasure.map({
-        case Some(Success(seq)) => seq.filter(_._2.typ == NUMBER)
-        case _ => Nil
-      }).map(_.map({ case (p, m, str) =>
-        <option selected={defaultNumPort.map(_ == Some(p))}
-                value={calcConversion(p, m).toString}>{str}</option>
-      }))
-    }
-  </select>.value()
-  //   Some((getIndexes(NUMBER), indexToNode))
-
-  private val (_densityIn, _density) =
-    <input class="form-control" type="number" step="any" min="0"
-           disabled={_volume.map(_ != customValue)}
-           value={_volume.map(_.toFloatOption)
-             .keepIf(_.nonEmpty)(Some(0))
-             .map(_.get.toString)} />.value()
-
-  private val (_massIn, _mass) =
-    <input class="form-control" type="number" step="any" min="0"
-           disabled={_count.map(_ != customValue)}
-           value={_count.map(_.toFloatOption)
-             .keepIf(_.nonEmpty)(Some(0))
-             .map(_.get.toString)} />.value()
-
-  private val (_unitTypeIn, _typ) = <select id="unit" class="form-control"
-    readonly={true}>
-  {
-    for (unitType <- UnitType.values.toSeq)
-    yield {<option value={unitType.toString}>{unitType.toString}</option>}
-  }
-  </select>.value()
-
-  private val _nutrientInputs = usda.nutrients.map({case (k, v) =>
-    (k, floatInput(k, Some(v)).value())
-  })
-
-  def floatInput(id: String, value: Option[Float]) = <input id={id}
-    class="form-control"
-    type="number"
-    step="any"
-    min="0"
-    value={value.getOrElse(0.0).toString} />
-
-  def getUSDANode() = (usda match {
-    case node: USDANodeId =>
-      Function.uncurried((USDANodeId.apply _).curried(node._id))
-        .asInstanceOf[(
-          String,
-          Long,
-          Option[Float],
-          Option[Float],
-          Float,
-          Map[String, Float]
-        ) => USDANodeId]
-    case _ =>
-      USDANodeNoId.apply _
-  })(
-    _name.value,
-    _fdcId,
-    _volume.flatMap({
-      case `noneValue` => Rx(None)
-      case `customValue` => _density.map(str => Some(str.toFloat))
-      case value => Rx(Some(value.toFloat))
-    }).value,
-    _count.flatMap({
-      case `noneValue` => Rx(None)
-      case `customValue` => _mass.map(str => Some(str.toFloat))
-      case value => Rx(Some(value.toFloat))
-    }).value,
-    //_typ.map(UnitType.withName).value,
-    _calorie.value.toFloat,
-    _nutrientInputs.map({case (k, v) => (k, v._2.value.toFloat)})
-  )
-
-  val undefinedMeasureUnitId = 9999
-
-  def portionStr(fp: FoodPortion) =
-    fp.portionDescription.getOrElse(fp.modifier.getOrElse("Unknown"))
-
-  def portionToMeasure(fp: FoodPortion, str: String): MeasureUnit =
-    if (fp.measureUnit.id != undefinedMeasureUnitId) Count
-    else MeasureUnit.guessUnit(str).getOrElse(Count)
-
-  val measureMatchThreshold = 0.01
-
-  /* Procedure to determine default:
-   * 1. Check if there is some value already:
-   * | yes:
-   * |- 2. Find the measure which most closely results in that value
-   * |  3. If closest match is not within the threshold of the value, use custom
-   * | no:
-   * |- 2. if defaultOverNone:
-   * |  | yes:
-   * |  |- 3. Use (portion, measure) which has the best matching between the two
-   * |  | no:
-   * |  |- 3. Use none
-   */
-  def getDefaultPortion(
-    portXmeas: Seq[(FoodPortion, MeasureUnit, String)],
-    typ: UnitType,
-    value: Option[Float]
-  ): Option[FoodPortion] = (value match {
-    case Some(dV) => portXmeas.filter(_._2.typ == typ).minByOption({
-      case (portion, measure, _) => {
-        val diff = (calcConversion(portion, measure) - dV).abs
-        if (diff < measureMatchThreshold) Some(diff)
-        else None
-      }
-    })
-    case None => if (defaultOverNone) MeasureUnit.closestPair(
-      portXmeas.filter(_._2.typ == typ)
-    )
-    else None
-  }).map(_._1)
-
-  def calcConversion(fp: FoodPortion, meas: MeasureUnit) =
-    fp.gramWeight/meas.conversionRatio
-
-  val render = {
-    <div class="form-group">
-      <div class="container">
-        <div class="row">
-          <div class="col-12">{ _nameIn }</div>
-        </div>
-        <div class="row">
-          <div class="col-6">
-            <label for="fdcid">USDA Id:</label>
-            <input id="fdcid"
-                   class="form-control"
-                   type="number"
-                   value={_fdcId.toString}
-                   readonly="readonly" />
-          </div>
-          <div class="col-6">
-            <label for="calories">{"Calories/100g:"}</label>
-            { _calorieIn }
-          </div>
-        </div>
-        <div class="row">
-          <div class="col-6">
-            <label for="density">Volume Unit:</label>
-           { _volumeIn }
-          </div>
-          <div class="col-6">
-           <label for="weight">Count Unit:</label>
-           { _countIn }
-          </div>
-        </div>
-        <div class="row">
-          <div class="col-6">
-            <label for="density">Density (g/ml):</label>
-           { _densityIn }
-          </div>
-          <div class="col-6">
-           <label for="weight">Mass (g/unit):</label>
-           { _massIn }
-          </div>
-        </div>
-        <div class="row">
-          <div class="col-6">
-            <label for="unit">Unit:</label>
-            { _unitTypeIn }
-          </div>
-        </div>
-      </div>
-      <div class="panel-group" style="margin: 1em;">
-        <div class="panel panel-default">
-          <div class="panel-heading">
-            <div class="panel-title">
-              <a href="#detailsDiv" data-toggle="collapse">Details</a>
-            </div>
-          </div>
-        </div>
-        <div id="detailsDiv" class="panel-collapse collapse">
-          <div class="panel-body">
-            <div class="container">
-              <div class="row">
-                {
-                  (for ((id, (input, _)) <- _nutrientInputs)
-                  yield {
-                    <div class="col-md-6">
-                      <label for={id}>
-                        {Nutrient.nutrients.map({
-                          case Some(Success(nutrs)) => nutrs
-                              .find(x => x.number == id)
-                              .map(x => s"${x.name} (${x.unitName})")
-                              .getOrElse({
-                                id.toString
-                              })
-                          case Some(Failure(err)) => {
-                            Console.err.println(err)
-                            id.toString
-                          }
-                          case None => id.toString
-                        })}:
-                      </label>
-                      { input }
-                    </div>
-                  }).toSeq
-                }
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-  }
-}

+ 0 - 234
client/src/main/scala/com/weEat/util/MHtmlHelpers.scala

@@ -1,234 +0,0 @@
-package com.weEat.util
-
-import mhtml.{Var,Rx}
-import org.scalajs.dom.raw._
-import scala.xml._
-
-object MHtmlHelpers {
-  implicit class RxHelpers[T](rx: Rx[T]) {
-    def value = {
-      var _value: Option[T] = None
-      rx.impure.run({x => _value = Some(x)}).cancel
-      _value.get
-    }
-
-    def doOnce(fn: T => Unit) = rx.impure.run(fn).cancel
-  }
-
-  implicit class RxFlattener[T](outer: Rx[Rx[T]]) {
-    def flatten = outer.flatMap(identity)
-  }
-
-  implicit class UnprefixedAttributeEmbeddable[T](prev: UnprefixedAttribute[T])
-      extends XmlAttributeEmbeddable[T]
-  implicit class PrefixedAttributeEmbeddable[T](prev: PrefixedAttribute[T])
-      extends XmlAttributeEmbeddable[T]
-
-  implicit class ElemHelpers(elm: Elem) {
-    def addAttribute[T](attr: String, value: T)
-      (implicit ev: XmlAttributeEmbeddable[T]) = {
-      def replacePrevVal: PartialFunction[MetaData, MetaData] = {
-        case UnprefixedAttribute(key, e, next) if key == attr => (value, e) match {
-          case (newFn: Function1[Event @ unchecked, Unit @ unchecked],
-            prevFn: Function1[Event @ unchecked, Unit @ unchecked]) =>
-            UnprefixedAttribute(key, { e: Event => prevFn(e); newFn(e) }, next)
-          case (newFn: Function0[Unit @ unchecked],
-            prevFn: Function1[Event @ unchecked, Unit @ unchecked]) =>
-            UnprefixedAttribute(key, { e: Event => prevFn(e); newFn() }, next)
-          case (newFn: Function1[Event @ unchecked, Unit @ unchecked],
-            prevFn: Function0[Unit @ unchecked]) =>
-            UnprefixedAttribute(key, { e: Event => prevFn(); newFn(e) }, next)
-          case (newFn: Function0[Unit @ unchecked],
-            prevFn: Function0[Unit @ unchecked]) =>
-            UnprefixedAttribute(key, { () => prevFn(); newFn() }, next)
-          case (value, _) => UnprefixedAttribute(key, value, next)
-        }
-        case upa: UnprefixedAttribute[_] =>
-          upa.copy(next = replacePrevVal(upa.next))(upa)
-        case pa: PrefixedAttribute[_] =>
-          pa.copy(next = replacePrevVal(pa.next))(pa, pa)
-        case Null => UnprefixedAttribute(attr, value, Null)
-      }
-      new Elem(elm.prefix, elm.label, replacePrevVal(elm.attributes1), elm.scope,
-        elm.minimizeEmpty, elm.child:_*)
-    }
-
-    def getAttribute(attr: String) = {
-      def findVal: PartialFunction[MetaData, Option[String]] = {
-        case UnprefixedAttribute(key, e, _) if key == attr => Some(e.toString)
-        case upa: UnprefixedAttribute[_] => findVal(upa.next)
-        case pa: PrefixedAttribute[_] => findVal(pa.next)
-        case Null => None
-      }
-      findVal(elm.attributes1)
-    }
-
-    def addClass(clss: String) = {
-      def replacePrevClass: PartialFunction[MetaData, MetaData] = {
-        case UnprefixedAttribute("class", e: scala.xml.Text, next) =>
-          UnprefixedAttribute("class", s"${e.data} $clss", next)
-        case UnprefixedAttribute("class", e: String, next) =>
-          UnprefixedAttribute("class", s"$e $clss", next)
-        case UnprefixedAttribute("class", e: Rx[String @ unchecked], next) =>
-          UnprefixedAttribute("class", e.map({x => s"$x $clss"}), next)
-        //case UnprefixedAttribute("class", e: Rx[scala.xml.Text], next) =>
-        //  UnprefixedAttribute("class", e.map({x => s"$x.data $clss"}), next)
-        case upa: UnprefixedAttribute[_] =>
-          upa.copy(next = replacePrevClass(upa.next))(upa)
-        case pa: PrefixedAttribute[_] =>
-          pa.copy(next = replacePrevClass(pa.next))(pa, pa)
-        case Null => UnprefixedAttribute("class", clss, Null)
-      }
-      new Elem(elm.prefix, elm.label, replacePrevClass(elm.attributes1), elm.scope,
-        elm.minimizeEmpty, elm.child:_*)
-    }
-
-    def removeClass(clss: String) = {
-      def rmClass(str: String) = "\\s".r.replaceAllIn(str.replace(clss, ""), " ")
-
-      def replacePrevClass: PartialFunction[MetaData, MetaData] = {
-        case UnprefixedAttribute("class", e: scala.xml.Text, next) =>
-          UnprefixedAttribute("class", rmClass(e.data), next)
-        case UnprefixedAttribute("class", e: String, next) =>
-          UnprefixedAttribute("class", rmClass(e), next)
-        case UnprefixedAttribute("class", e: Rx[String @ unchecked], next) =>
-          UnprefixedAttribute("class", e.map(rmClass), next)
-        //case UnprefixedAttribute("class", e: Rx[scala.xml.Text], next) =>
-        //  UnprefixedAttribute("class", e.map({x => rmClass(x.data)}), next)
-        case upa: UnprefixedAttribute[_] =>
-          upa.copy(next = replacePrevClass(upa.next))(upa)
-        case pa: PrefixedAttribute[_] =>
-          pa.copy(next = replacePrevClass(pa.next))(pa, pa)
-        case Null => UnprefixedAttribute("class", clss, Null)
-      }
-      new Elem(elm.prefix, elm.label, replacePrevClass(elm.attributes1), elm.scope,
-        elm.minimizeEmpty, elm.child:_*)
-    }
-
-    def beforeMount(fn: (HTMLElement => _)) = {
-      addAttribute("mhtml-onmount", {e: HTMLElement =>
-        fn(e)
-        ()
-      })
-    }
-
-    def afterMount(fn: (HTMLElement => _)) = {
-      addAttribute("mhtml-onmount", {e: HTMLElement =>
-        scala.scalajs.js.timers.setTimeout(0) {
-          fn(e)
-        }
-        ()
-      })
-    }
-
-    def beforeUnmount(fn: (HTMLElement => _)) = {
-      addAttribute("mhtml-onmount", {e: HTMLElement =>
-        fn(e)
-        ()
-      })
-    }
-
-    def afterUnmount(fn: (HTMLElement => _)) = {
-      addAttribute("mhtml-onmount", {e: HTMLElement =>
-        scala.scalajs.js.timers.setTimeout(0) {
-          fn(e)
-        }
-        ()
-      })
-    }
-
-    def value(value: Rx[String]) = elm.label.toLowerCase match {
-      case "input" => afterMount { elm =>
-        val input = elm.asInstanceOf[HTMLInputElement]
-        value.map(input.value = _)
-        ()
-      }
-      case "select" => afterMount { elm =>
-        val input = elm.asInstanceOf[HTMLSelectElement]
-        value.map(input.value = _)
-        ()
-      }
-      case _ => ???
-    }
-
-    import scala.scalajs.js
-    private def inputObserver(value: Var[String])
-      (recs: js.Array[MutationRecord], obs: MutationObserver) = {
-      if (recs.length > 0)
-        value := recs(0).target.asInstanceOf[HTMLInputElement].value
-    }
-
-    private def selectObserver(value: Var[String])
-      (recs: js.Array[MutationRecord], obs: MutationObserver) = {
-      if (recs.length > 0)
-        value := recs(0).target.asInstanceOf[HTMLSelectElement].value
-    }
-    
-    def value() = elm.label.toLowerCase match {
-      case "input" => {
-        val value = Var[String](getAttribute("value").getOrElse(""))
-        val observer = new MutationObserver(inputObserver(value))
-        (addAttribute("oninput", { e: Event =>
-          val input = e.target.asInstanceOf[HTMLInputElement]
-          value := input.value
-          ()
-        }).afterMount({ input =>
-          value := input.asInstanceOf[HTMLInputElement].value
-          observer.observe(input, MutationObserverInit(
-            attributes = true,
-            attributeFilter = js.Array("value")
-          ))
-          ()
-        }).beforeUnmount { input =>
-          observer.disconnect()
-          ()
-        }, value.impure.sharing)
-      }
-      case "select" => {
-        // TODO: Get a more robust default value for select elements
-        val value = Var[String]("")
-        val observer = new MutationObserver(selectObserver(value))
-        (addAttribute("onchange", { e: Event =>
-          val input = e.target.asInstanceOf[HTMLSelectElement]
-          value := input.asInstanceOf[HTMLInputElement].value
-          ()
-        }).afterMount({ input =>
-          value := input.asInstanceOf[HTMLSelectElement].value
-          observer.observe(input, MutationObserverInit(
-            attributes = true,
-            attributeFilter = js.Array("value"),
-            childList = true,
-            subtree = true
-          ))
-          ()
-        }).beforeUnmount { input =>
-          observer.disconnect()
-          ()
-        }, value.impure.sharing)
-      }
-      case _ => ???
-    }
-
-    def checked(value: Rx[Boolean]) = elm.label.toLowerCase match {
-      case "input" => afterMount { elm =>
-        val input = elm.asInstanceOf[HTMLInputElement]
-        value.map(input.checked = _)
-        ()
-      }
-      case _ => ???
-    }
-
-    def checked() = elm.label.toLowerCase match {
-      case "input" =>
-        val value = Var[Boolean](
-          elm.getAttribute("checked").map(_.toBoolean).getOrElse(false)
-        )
-        (addAttribute("onchange", { e: Event =>
-          val input = e.currentTarget.asInstanceOf[HTMLInputElement]
-          value.update(_ => input.checked)
-        }), value.impure.sharing)
-      case _ => ???
-    }
-
-  }
-}

+ 0 - 16
client/src/main/scala/com/weEat/util/TimeoutHelper.scala

@@ -1,16 +0,0 @@
-package com.weEat.util
-
-import scala.scalajs.js.timers.setTimeout
-import scala.concurrent.duration.FiniteDuration
-import scala.concurrent.{ExecutionContext,Future,Promise}
-
-object TimeoutHelper {
-  def setTimeoutResult[T](delay: FiniteDuration)(body: => T)
-    (implicit ec: ExecutionContext) = {
-    val promise = Promise[T]()
-    setTimeout(delay) {
-      promise.success(body)
-    }
-    promise.future
-  }
-}

+ 0 - 29
client/src/main/scala/com/weEat/views/FoodSearch.scala

@@ -1,29 +0,0 @@
-package com.weEat.view
-
-import com.weEat.controllers.{FoodController,USDAController}
-import com.weEat.modules._
-import com.weEat.shared.models.UnitType._
-import com.weEat.shared.models.USDANodeNoId
-import com.weEat.util.MHtmlHelpers._
-import gov.usda.nal.fdc.models._
-import gov.usda.nal.fdc.models.DataType._
-import mhtml.Rx
-import mhtml.future.syntax._
-import org.scalajs.dom.raw.Event
-import scala.util.Success
-
-object FoodSearch extends View {
-  import com.weEat.Main.headers
-
-  implicit val ctx = scala.concurrent.ExecutionContext.global
-
-  val tag = "foodsearch"
-
-  val title = "Search Foods"
-
-  def content = {
-    <div>
-    <h2>Search Foods</h2>
-    </div>
-  }
-}

+ 0 - 320
client/src/main/scala/com/weEat/views/RecipeEdit.scala

@@ -1,320 +0,0 @@
-package com.weEat.view
-
-import com.weEat.controllers.{FoodController,USDAController}
-import com.weEat.modules._
-import com.weEat.shared.models.UnitType._
-import com.weEat.shared.models._
-import com.weEat.util.MHtmlHelpers._
-import mhtml.{Rx,Var}
-import mhtml.future.syntax._
-import org.scalajs.dom.raw.{Event,KeyboardEvent,HTMLInputElement,HTMLElement}
-import scala.util.{Success,Failure}
-import mhtml.future.syntax._
-import com.tflucke.sortable.{Sortable, SortableOptions, SortableEvent}
-import scala.scalajs.js.timers.setTimeout
-import com.tflucke.typeahead._
-import com.tflucke.typeahead.Dataset.Templates
-import gov.usda.nal.fdc.models.SearchResultFood
-import org.scalajs.dom.document
-import gov.usda.nal.fdc.models.DataType._
-
-// TODO: prevent user from not having any of discreet/mass/volume input 
-// TODO: Save recipe node in cookie until ready to use
-object RecipeEdit extends View {
-  import com.weEat.Main.headers
-
-  implicit val ctx = scala.concurrent.ExecutionContext.global
-
-  val tag = "editRecipe"
-
-  val title = "Edit Recipe"
-
-  val ENTER_KEY_CODE = 13
-
-  private val (_nameIn, _name) =
-    <input id="name" class="form-control" required={true} />.value()
-
-  private val internalFoodDS = Dataset(
-    { _ => Nil},
-    Some({ str: String => FoodController.query(str)() }),
-    display = { food: FoodNodeId => food.name }
-  )
-
-  private val usdaFoodDS = Dataset(
-    { _ => Nil},
-    Some({ str: String =>
-      USDAController.getFoodsSearch(str, Seq(
-        Foundation, Survey, SRLegacy
-      ).map(_.toString))().map(_.foods.map(USDANodeNoId.fromSearchResult))
-    }),
-    templates = Some(Templates({x: USDANodeNoId => x.name}).copy(
-      header = Some({ (_: String, _: Seq[USDANodeNoId], _: String) =>
-        val html = document.createElement("div").asInstanceOf[HTMLElement]
-        html.innerText = "Unoffical Foods"
-        html
-      })
-    )),
-    display = {x: USDANodeNoId => x.name}
-  )
-
-  private val _ingredientSearch =
-    <input type="text" class="form-control input-sm" />.afterMount { elm =>
-      TypeaheadElement[FoodNode](
-        elm.asInstanceOf[HTMLInputElement],
-        minLength = 3
-      )(Seq(internalFoodDS, usdaFoodDS))
-      elm.addEventListener("typeahead:selected", {e: CursorEvent[FoodNode] =>
-        e.selectable.map(_.data).foreach({ node =>
-          // TODO: default unit
-          _editIngredient(Ingredient.fromFoodNode(node, 0, Gram)) { in =>
-            _ingredients.update(_ :+ in)
-          }
-        })
-      })
-    }
-
-  private val _stepIn = <input id="step" class="form-control"
-    onkeypress={ e: KeyboardEvent => if (e.keyCode == ENTER_KEY_CODE) {
-      val elm = e.target.asInstanceOf[HTMLInputElement]
-      _steps.update(_ :+ elm.value)
-      elm.value = ""
-    } } />
-
-  private val _ingredients = Var[Seq[Ingredient]](Nil)
-  private val _steps = Var[Seq[String]](Nil)
-
-  private val (_servingsIn, _servings) =
-    <input id="serv" type="number" class="form-control" value="4" step=".1"
-           required={true} />.value()
-
-  private val (_discreetIn, _discreet) =
-    <input id="discreet" type="checkbox" class="form-check-input" checked={true} />
-      .checked()
-
-  // TODO: Try autopopulate ingredients.sum(_.massPerUnit)
-  private val (_massPIn, _massP) =
-    <input id="massP" type="number" min="1" class="form-control" step=".1" />
-      .value()
-
-  // TODO: Try autopopulate ingredients.averge(_.density)
-  private val (_volPIn, _volP) =
-    <input id="volP" type="number" min="0" class="form-control col-6" step=".1" />
-      .value()
-
-  private val (_volPUnitIn, _volPUnit) =
-    <select class="custom-select col-6">
-    { MeasureUnit.units.zipWithIndex
-      .filter({ case (u, _) => u.typ == VOLUME })
-      .map({ case (unit, idx) =>
-        // TODO: default selected dynamic
-        <option value={idx.toString} selected={unit.abr == "mL"}>
-        {unit.name}
-        </option>
-      })
-    }
-    </select>.value()
-
-  val recipieNode = _ingredients.zip(_steps)
-    .zip(_name)
-    .zip(_discreet)
-    .zip(_servings)
-    .zip(_volP)
-    .zip(_volPUnit)
-    .zip(_massP)
-    .map({
-      case (((((((
-        ingredients,
-        steps),
-        name),
-        discreet),
-        servings),
-        volP),
-        volPUnit),
-        massP) =>
-        val gPServ = massP.toFloatOption
-        val mLPServ = volP.toFloatOption
-          .map( _ * MeasureUnit(volPUnit.toInt).conversionRatio )
-        val (servFactor, servUnit) = gPServ.zip(Some(Gram))
-          .getOrElse(
-            if (discreet) (1.0f, Count)
-            else mLPServ.zip(Some(Milliliter)).getOrElse(???)
-          ).asInstanceOf[(Float, MeasureUnit)]
-        val numServings = servings.toFloatOption.getOrElse(1.0f)
-        val stdQtiesPServing = servFactor / servUnit.typ.standardQuanity
-        val stdQties = numServings * stdQtiesPServing
-
-        RecipeNodeNoId(
-          name,
-          stdQties,
-          stdQtiesPServing,
-          servUnit.typ,
-          ingredients,
-          steps,
-          gPServ.zip(mLPServ).map({ case (m, v) => (m/v).toFloat }),
-          gPServ
-        )
-    })
-
-  private def _editIngredient(ing: Ingredient)(callback: (Ingredient => _)) = {
-    Overlay.loading(ing.food.map({ food =>
-      val (amountIn, amount) = <input type="number" min="0"
-      class="form-control input-sm col-9"
-      value={ing.amount.toString} />.value()
-      val (idxIn, idx) = <select class="col-3 custom-select">
-      { MeasureUnit.units.zipWithIndex
-        .map({ case (unit, idx) =>
-          // TODO: default selected dynamic
-          <option value={idx.toString} selected={unit.abr == "g"}>
-          {unit.name}
-          </option>
-        })
-      }
-      </select>.value()
-      Overlay.confirm(Rx(<div class="row">{amountIn}{idxIn}</div>)) { () =>
-        callback(ing.copy(
-          amount = amount.value.toFloat,
-          unit = MeasureUnit.units(idx.value.toInt)
-        ))
-      }
-    }))
-  }
-
-  private def _removeFromList[T](s: Seq[T], idx: Int) =
-    s.take(idx) ++ s.drop(idx + 1)
-
-  private val _stepList =
-    <ol style="list-style-type: none; padding-left: 0;">
-      { _steps.map(_.zipWithIndex.map((presentStep _).tupled)) }
-    </ol>.afterMount(Sortable.create(_, SortableOptions.onUpdate({
-      event: SortableEvent => 
-        _steps.update({ steps =>
-          (event.oldIndex.toOption, event.newIndex.toOption) match {
-            case (Some(old), Some(ne)) if (old < ne) =>
-              moveBackwards(steps, old, ne)
-            case (Some(old), Some(ne)) =>
-              moveBackwards(
-                steps.reverse,
-                steps.size - old - 1,
-                steps.size - ne - 1
-              ).reverse
-            case (None, Some(ne)) =>
-              val (first, second) = steps.splitAt(ne)
-              (first :+ event.item.innerText) ++ second
-            case (Some(old), None) =>
-              _removeFromList(steps, old)
-          }
-        })
-        // If the added element is still in the parent, remove it since the
-        // Rx.update will have generated a new one.
-        Option(event.item.parentElement).map(_.removeChild(event.item))
-
-        def moveBackwards(steps: Seq[String], old: Int, ne: Int) =
-          (steps.take(old) ++
-            steps.take(ne+1).drop(old + 1) :+
-            steps(old)) ++
-            steps.drop(ne+1)
-      })))
-
-  def presentFoodNode(ingredient: Ingredient, idx: Int) = {
-    // TODO: Convert to user preferred units
-    ingredient.food.toRx.map({
-      case Some(Success(food)) =>
-        <li>
-          <span class="ui-icon ui-icon-pencil" onclick={ e: Event =>
-            _editIngredient(ingredient) { in =>
-              _ingredients.update(_.updated(idx, in))
-            }
-            ()
-          }></span>
-          <span class="ui-icon ui-icon-close" onclick={ e: Event =>
-            _ingredients.update(_.filterNot(_ == ingredient))
-          }></span>
-          {f"${ingredient.amount}%.0f${ingredient.unit.abr} ${food.name}"}
-        </li>
-      case None => <li></li>
-      case x => {println(x);???}
-    })
-  }
-
-  def presentStep(step: String, idx: Int) = {
-    <li>
-      <span class="ui-icon ui-icon-close" onclick={ e: Event =>
-        _steps.update(_removeFromList(_, idx))
-      }></span>
-      {step}
-    </li>
-  }
-
-  def content = {
-    <div>
-    <h2>Recipe Editor</h2>
-    <div class="form-group">
-      <div class="container">
-        <div class="row">
-          <div class="col-md-12">
-            <label>Name: </label>{ _nameIn }
-          </div>
-        </div>
-        <div class="row">
-          <div class="col-md-3">
-            <div class="form-check">
-              { _discreetIn }
-              <label class="form-check-label" for="discreet">
-                Descrete Servings
-              </label>
-            </div>
-          </div>
-          <div class="col-md-3">
-            <label># Servings: </label>
-            { _servingsIn }
-          </div>
-          <div class="col-md-3">
-            <label>Grams/Serving: </label>
-            { _massPIn }
-          </div>
-          <div class="col-md-3">
-            <div class="container">
-              <label>Volume/Serving: </label>
-              <div class="row">
-                { _volPIn } { _volPUnitIn }
-              </div>
-            </div>
-          </div>
-        </div>
-        <div class="row">
-          <div class="col-md-5">
-            <h2>Ingredients</h2>
-            { _ingredientSearch }
-            <ul style="list-style-type: none; padding-left: 0;">
-              { _ingredients.map(_.zipWithIndex.map((presentFoodNode _).tupled)
-                .foldRight(Rx(Seq[scala.xml.Elem]()))(for {n<-_; s<-_} yield n+:s)
-              ).flatten }
-            </ul>
-          </div>
-          <div class="col-md-5">
-            <h2>Steps</h2>
-           { _stepIn }
-           { _stepList }
-          </div>
-          <div class="col-md-2">
-            { NutritionPane(recipieNode).render }
-          </div>
-        </div>
-      </div>
-      <button class="btn" onclick={ e: Event =>
-        import play.api.libs.json.Json
-        println(recipieNode.value)
-        println(Json.toJson(recipieNode.value))
-        println(Json.stringify(Json.toJson(recipieNode.value)))
-        FoodController.add()(recipieNode.value).onComplete {
-          case Success(_) => println("Success!")
-          case Failure(ex) =>
-            println("Could not add recipe")
-            throw ex
-        }
-        ()
-      }>Add</button>
-    </div>
-    </div>
-  }
-}

+ 0 - 86
client/src/main/scala/com/weEat/views/UsdaImporter.scala

@@ -1,86 +0,0 @@
-package com.weEat.view
-
-import com.weEat.controllers.{FoodController,USDAController}
-import com.weEat.modules._
-import com.weEat.shared.models.UnitType._
-import com.weEat.shared.models.USDANodeNoId
-import com.weEat.util.MHtmlHelpers._
-import gov.usda.nal.fdc.models._
-import gov.usda.nal.fdc.models.DataType._
-import mhtml.Rx
-import mhtml.future.syntax._
-import org.scalajs.dom.raw.Event
-import scala.util.Success
-
-object UsdaImporter extends View {
-  import com.weEat.Main.headers
-
-  implicit val ctx = scala.concurrent.ExecutionContext.global
-
-  val tag = "importer"
-
-  val title = "USDA Import"
-
-  override val permissions = Set("admin")
-
-  def content = {
-    val searchBar = SearchBar(term => USDAController.getFoodsSearch(term, Seq(
-      Foundation, Survey, SRLegacy
-    ).map(_.toString))().map({
-      // TODO: Save on network calls by waiting until we're near the end of each
-      // page before loading the next one.
-      // Represent this as a Seq[Rx[(Boolean, Criteria)]] where each node depends
-      // on the one before it in the sequence.
-      case SearchResult(criteria, n, cur, tot, baseList) => (cur + 1 to tot).map({
-        c => USDAController.postFoodsSearch()(criteria.copy(pageNumber = Some(c)))
-          .map(_.foods).toRx.map({
-            case Some(Success(l)) => l
-            case _ => Nil
-          })
-      }).foldLeft(Rx(baseList))({ (soFar, cur) => soFar.zip(cur).map({
-        case (soFarList, curList) => soFarList :++ curList
-      }) })
-    }))
-    val searchResults = searchBar.result.flatMap({
-      case Some(Success(inner)) => inner
-      case _ => Rx(Nil)
-    })
-    val pageSizeSel = Select(Rx((10 to 40 by 10)))
-    val numPages = pageSizeSel.value.zip(searchResults).map({
-      case (size, results) => (results.length + size - 1) / size
-    })
-    val pageSel = PageSelect(numPages)
-
-    <div>
-    <h2>USDA Importer</h2>
-    <div class="form-group">
-      <label for="search">Search: </label>
-      { searchBar.render }
-    </div>
-    {
-      PaginatedTable[SearchResultFood](Seq(
-        ("", 1, {x => Rx(<button class="btn btn-light" onclick={e: Event =>
-          // defaultUnit = if (num().nonEmpty) NUMBER else MASS
-          val editor = USDAEditor(USDANodeNoId.fromSearchResult(x), true)
-          Overlay.confirm(Rx(editor.render)) { () =>
-            import com.weEat.Main.headers
-            FoodController.add()(editor.getUSDANode())
-          }
-          ()
-        }>Add</button>)}),
-        ("ID", 1, {x => Rx(<span>{x.fdcId.toString}</span>)}),
-        ("Name", 3, {x => Rx(<span>{x.description}</span>)}),
-        ("Desc", 4, {x => Rx(<span>
-            {x.additionalDescriptions.getOrElse("")}
-          </span>)}),
-        ("Brand", 3, {x => Rx(<span>{x.brandOwner.getOrElse("")}</span>)})
-      ),
-        searchResults,
-        pageSel.page,
-        pageSizeSel.value).render
-    }
-    { (pageSel.render).addClass("col-3 float-left") }
-    { (pageSizeSel.render).addClass("col-1 float-right") }
-    </div>
-  }
-}

+ 0 - 31
client/src/main/scala/com/weEat/views/UserManage.scala

@@ -1,31 +0,0 @@
-package com.weEat.view
-
-import com.weEat.controllers.{FoodController,USDAController}
-import com.weEat.modules._
-import com.weEat.shared.models.UnitType._
-import com.weEat.shared.models.USDANodeNoId
-import com.weEat.util.MHtmlHelpers._
-import gov.usda.nal.fdc.models._
-import gov.usda.nal.fdc.models.DataType._
-import mhtml.Rx
-import mhtml.future.syntax._
-import org.scalajs.dom.raw.Event
-import scala.util.Success
-
-object UserManage extends View {
-  import com.weEat.Main.headers
-
-  implicit val ctx = scala.concurrent.ExecutionContext.global
-
-  val tag = "umanage"
-
-  val title = "Manage Users"
-
-  override val permissions = Set("admin")
-
-  def content = {
-    <div>
-    <h2>Manage Users</h2>
-    </div>
-  }
-}

+ 0 - 49
client/src/main/scala/com/weEat/views/View.scala

@@ -1,49 +0,0 @@
-package com.weEat.view
-
-import com.weEat.OAuthManager
-import org.scalajs.dom.document
-import scala.util.{Try,Success}
-
-trait View {
-  def tag: String
-  def title: String
-  def content: scala.xml.Node
-  def permissions: Set[String] = Set.empty
-
-  def present() = {
-    document.title =
-      document.title.split(View.titleSeperator)(0) + View.titleSeperator + title
-    val children = View._contentDiv.children
-    for (i <- (0 to children.length - 1)) {
-      View._contentDiv.removeChild(children(i))
-    }
-    mhtml.mount(View._contentDiv, content)
-  }
-
-  def init(args: Map[String, String]): Try[Unit] = Success(())
-  def dispose(): Unit = ()
-}
-
-object View {
-  private val _contentDiv = document.getElementById("content")
-
-  def home = RecipeEdit
-  val titleSeperator = " - "
-
-  def index = Seq(
-    UsdaImporter,
-    FoodSearch,
-    RecipeEdit,
-    RecipeView,
-    UserManage,
-    ProfileView,
-    ProfileEdit
-  )
-
-  def authedIndex = OAuthManager.isAuthedFor("admin").map(isAdmin =>
-    if (isAdmin) index else index.filterNot(_.permissions.contains("admin"))
-  )
-
-  def fromTagOpt(tag: String) = index.find(_.tag == tag)
-  def fromTag(tag: String) = fromTagOpt(tag).getOrElse(home)
-}

+ 1 - 1
fdc/js/src/main/scala/gov/usda/nal/fdc/DateHelper.scala

@@ -25,7 +25,7 @@ object DateHelper {
 
   implicit val dateWrites = new Writes[Date] {
     def writes(d: Date): JsValue = JsString(
-      s"${d.getMonth.toInt + 1}/${d.getDate.toInt}/${d.getFullYear.toInt}"
+      s"${d.getMonth().toInt + 1}/${d.getDate().toInt}/${d.getFullYear().toInt}"
     )
   }
 }

+ 5 - 4
fdc/shared/src/main/scala/gov/usda/nal/fdc/controllers/FoodController.scala

@@ -211,11 +211,12 @@ class FoodController(apiKey: String) {
     sortOrder: Option[SortOrder] = None
   ) = new APIRoute[SearchResult](
     "GET",
-    s"$server/v1/foods/search?api_key=$apiKey&query=$query" + (
+    s"$server/v1/foods/search?api_key=$apiKey&query=${Http.encode(query)}" + (
       if (dataType.length > 4) throw new IllegalArgumentException(
         "dataType must contain at most 4 elements."
       )
-      else if (dataType.nonEmpty) "&dataType=" + dataType.mkString(",")
+      else if (dataType.nonEmpty)
+        "&dataType=" + dataType.map((dt) => Http.encode(dt.toString)).mkString(",")
       else ""
     ) + (pageSize match {
       case Some(size) if size > 200 => throw new IllegalArgumentException(
@@ -231,8 +232,8 @@ class FoodController(apiKey: String) {
       sortOrder.map(x => s"&sortOrder=$x").getOrElse(""),
     "application/json"
   ) {
-    def convert(xhr: Http.Response) =
-      Json.using[Json.WithDefaultValues].parse(xhr.responseText).as[SearchResult]
+    def convert(xhr: Http.Response) ={
+      Json.using[Json.WithDefaultValues].parse(xhr.responseText).as[SearchResult]}
     def acceptHeader = "application/json"
   }
 

+ 1 - 4
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodItem.scala

@@ -138,11 +138,8 @@ object SurveyFoodItem {
 }
 
 object FoodItem {
-  import play.api.libs.functional.syntax._
   import play.api.libs.json.{Json,Reads,Writes}
-  import java.util.Calendar
-  import java.text.ParseException
-
+  
   implicit val requestWrites = Writes[FoodItem]({
     case item: AbridgedFoodItem => Json.toJson(item)
     case item: BrandedFoodItem => Json.toJson(item)

+ 0 - 1
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodSearchCriteria.scala

@@ -1,6 +1,5 @@
 package gov.usda.nal.fdc.models
 
-import gov.usda.nal.fdc.models.DataType.DataType
 import gov.usda.nal.fdc.models.SortCriteria.SortCriteria
 import gov.usda.nal.fdc.models.SortOrder.SortOrder
 

+ 0 - 2
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/InputFood.scala

@@ -1,7 +1,5 @@
 package gov.usda.nal.fdc.models
 
-import gov.usda.nal.fdc.models.DataType.DataType
-
 sealed trait InputFood {
   def id: Long
   def foodDescription: Option[String] = None

+ 1 - 1
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SearchResultFood.scala

@@ -7,7 +7,7 @@ case class SearchResultFood(
   val fdcId: Long,
   val dataType: DataType,
   val description: String,
-  val lowercaseDescription: String,
+  val lowercaseDescription: Option[String],
   val foodCode: Option[Long] = None,
   val foodNutrients: Seq[AbridgedFoodNutrient],
   val publicationDate: Option[Date],

+ 1 - 1
project/build.properties

@@ -1 +1 @@
-sbt.version=1.3.10
+sbt.version=1.7.1

+ 3 - 3
project/plugins.sbt

@@ -1,5 +1,5 @@
-addSbtPlugin("org.scala-js"       % "sbt-scalajs"               % "1.3.0")
-addSbtPlugin("com.typesafe.play"  % "sbt-plugin"                % "2.8.2")
+addSbtPlugin("org.scala-js"       % "sbt-scalajs"               % "1.13.0")
+addSbtPlugin("com.typesafe.play"  % "sbt-plugin"                % "2.8.9")
 addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject"  % "1.0.0")
 addSbtPlugin("com.vmunier"        % "sbt-web-scalajs"           % "1.0.11")
-addSbtPlugin("com.tflucke"       % "sbt-rest-rpc-play"          % "0.3.1")
+addSbtPlugin("com.tflucke"        % "sbt-rest-rpc-play"          % "0.3.2")

+ 3 - 6
server/app/com/weEat/controllers/FoodController.scala

@@ -3,15 +3,12 @@ package com.weEat.controllers
 import com.weEat.models.{FoodNode => FoodNodeCollection}
 import com.weEat.services.MongoDBService
 import com.weEat.shared.models._
-import com.weEat.shared.models.UnitType._
 import javax.inject.{Inject,Singleton}
-import play.api._
 import play.api.libs.json._
 import play.api.mvc._
 import org.bson.types.ObjectId
 import org.mongodb.scala.model.Filters._
-import org.mongodb.scala.model.Updates._
-import scala.concurrent.{ExecutionContext,Future}
+import scala.concurrent.Future
 import scala.util.{Success,Failure}
 import com.weEat.models.Authorization
 import scalaoauth2.provider.{AuthInfoRequest,OAuth2ProviderActionBuilders}
@@ -56,7 +53,7 @@ class FoodController @Inject()(
   def query(q: String) = Action.async
   { implicit request: Request[AnyContent] =>
     withCollection(FoodNodeCollection) {collection =>
-      collection.find(regex("name", s".*$q.*"))
+      collection.find(regex("name", q, "i"))
         .toFuture()
         .transform({
           case Success(x) => Success(Ok(Json.toJson(x)))
@@ -141,7 +138,7 @@ class FoodController @Inject()(
       }.flatten
     }
     catch {
-      case jsre: JsResultException => Future.successful(
+      case _: JsResultException => Future.successful(
         BadRequest(s"Could not parse json into a Food node.")
       )
     }

+ 1 - 7
server/app/com/weEat/controllers/USDAController.scala

@@ -4,16 +4,10 @@ import javax.inject.{Inject,Singleton}
 import play.api._
 import play.api.mvc._
 import play.api.libs.json._
-import gov.usda.nal.fdc.{controllers => fdc}
 import gov.usda.nal.fdc.models._
 import gov.usda.nal.fdc.models.FoodsCriteria.Format
-import gov.usda.nal.fdc.models.DataType.DataType
-import gov.usda.nal.fdc.models.SortCriteria.SortCriteria
-import gov.usda.nal.fdc.models.SortOrder.SortOrder
-import scala.util.{Try,Success,Failure}
+import scala.util.{Success,Failure}
 import com.tflucke.webroutes.HTTPException
-import com.weEat.shared.util.TryWith
-import scala.concurrent.{ExecutionContext,Future,blocking}
 import gov.usda.nal.fdc.{controllers => fdcControllers}
 import org.mongodb.scala.model.Filters._
 import com.weEat.services.MongoDBService

+ 3 - 7
server/app/com/weEat/controllers/UserController.scala

@@ -2,22 +2,18 @@ package com.weEat.controllers
 
 import java.util.Base64
 import javax.inject.{Inject,Singleton}
-import play.api._
 import play.api.mvc._
 import play.api.libs.json._
 import com.weEat.services.OAuth2Service
 import com.weEat.models.{Authorization,User}
 import com.weEat.shared.models.UserRegistration
-import com.github.t3hnar.bcrypt._
 import scalaoauth2.provider._
-import scala.concurrent.duration._
-import scala.concurrent.{Await,Future,blocking}
-import scala.util.{Try,Success,Failure}
+import scala.concurrent.Future
+import scala.util.{Success,Failure}
 import org.mongodb.scala.ObservableImplicits
 import com.weEat.services.MongoDBService
 import org.mongodb.scala.model.Filters._
 import org.bson.types.ObjectId
-import org.mongodb.scala.MongoCollection
 
 @Singleton
 class UserController @Inject()(
@@ -109,7 +105,7 @@ class UserController @Inject()(
         val user = User(body)
         withCollection(User) { collection =>
           {
-          collection.insertOne(user).head().map(res =>
+          collection.insertOne(user).head().map(_ =>
             {
             issueAccessToken(oauth)(Request[Map[String, Seq[String]]](
               request.withHeaders(Headers(

+ 3 - 7
server/app/com/weEat/controllers/ViewController.scala

@@ -1,13 +1,9 @@
 package com.weEat.controllers
 
 import javax.inject._
-import play.api._
 import play.api.mvc._
 import com.weEat.services.MongoDBService
-import play.api.libs.json.JsValue
 import com.weEat.models.User
-import com.weEat.shared.models.UserRegistration
-import scala.util.{Try,Success,Failure}
 import scala.concurrent.Future
 import org.bson.types.ObjectId
 
@@ -35,7 +31,7 @@ class ViewController @Inject()(
       case null => Ok(views.html.initalizer())
       case _ =>
         initalized = true;
-        MovedPermanently(routes.ViewController.loader().url)
+        MovedPermanently(routes.ViewController.loader("").url)
     }
   }
 
@@ -59,11 +55,11 @@ class ViewController @Inject()(
       }
     }.flatten.map({ _ =>
       initalized = true;
-      MovedPermanently(routes.ViewController.loader().url)
+      MovedPermanently(routes.ViewController.loader("").url)
     })
   }
 
-  def loader() = Action { implicit request: Request[AnyContent] =>
+  def loader(s: String) = Action { implicit request: Request[AnyContent] =>
     if (initalized)
       Ok(views.html.viewLoader())
     else

+ 4 - 4
server/app/com/weEat/migrations/InitDb.scala

@@ -30,7 +30,7 @@ object InitDb extends Migration {
       .capped(true)
       .maxDocuments(1)
       .sizeInBytes(MAX_SIZE_META_DOCUMENT)
-    ).head().map(res =>
+    ).head().map(_ =>
       db.getCollection[Metadata](Metadata.collectionName)
         .insertOne(Metadata())
         .head()
@@ -53,7 +53,7 @@ object InitDb extends Migration {
           )
         )
       )
-    ).head().map(res => db.getCollection[User](User.collectionName)
+    ).head().map(_ => db.getCollection[User](User.collectionName)
       // TODO: this would be better as hashed
       // Currently ascending because hashed does not support unique
       .createIndex(ascending("email"), new IndexOptions().unique(true)).head()
@@ -72,7 +72,7 @@ object InitDb extends Migration {
           )
         )
       )
-    ).head().map(res =>
+    ).head().map(_ =>
       db.getCollection[Authorization](Authorization.collectionName)
         .createIndexes(Seq(
           // TODO: These two would be better as hashed
@@ -82,7 +82,7 @@ object InitDb extends Migration {
           new IndexModel(ascending("refreshToken"), new IndexOptions()
             .unique(true)),
           new IndexModel(ascending("created"),new IndexOptions()
-            .expireAfter(Authorization.refreshFreshTime.toSeconds, SECONDS)
+            .expireAfter(Authorization.refreshFreshTime.getSeconds(), SECONDS)
           )
         )).head()
     ).flatten

+ 0 - 2
server/app/com/weEat/migrations/RestoreFromFile.scala

@@ -3,10 +3,8 @@ package com.weEat.migrations
 import com.weEat.models.Collectable
 import org.mongodb.scala.MongoDatabase
 import scala.io.Source
-import scala.concurrent.Future
 import scala.reflect.ClassTag
 import play.api.libs.json.{Reads,Json}
-import scala.jdk.StreamConverters._
 
 case class RestoreFromFile[T](
   f: Source,

+ 1 - 3
server/app/com/weEat/migrations/SeedNutrition.scala

@@ -2,11 +2,9 @@ package com.weEat.migrations
 
 import org.mongodb.scala.MongoDatabase
 import gov.usda.nal.fdc.models.Nutrient
-import com.weEat.shared.models.FoodNode
 import com.mongodb.client.model._
 import org.mongodb.scala.model.Filters._
 import org.mongodb.scala.model.Indexes._
-import java.util.concurrent.TimeUnit._
 import org.bson.BsonType._
 import scala.concurrent.{ExecutionContext,Future}
 import com.weEat.models.{Nutrient => NutrientCollection}
@@ -34,7 +32,7 @@ object SeedNutrition extends Migration {
           )
         )
       )
-    ).head().map(res => {
+    ).head().map(_ => {
       val nutrs = db.getCollection[Nutrient](NutrientCollection.collectionName)
       nutrs.createIndex(
         ascending("number"), new IndexOptions().unique(true)

+ 4 - 1
server/app/com/weEat/models/User.scala

@@ -6,9 +6,10 @@ import com.weEat.shared.models.UserAuthorization
 import java.security.SecureRandom
 import java.time.{Duration,Instant}
 import java.util.Base64
-import scala.concurrent.duration.FiniteDuration
 import scala.language.postfixOps
 import scalaoauth2.provider.AccessToken
+import scala.language.existentials
+import scala.language.implicitConversions
 
 /* Basic User information */
 case class User (
@@ -64,6 +65,7 @@ class Authorization (
     Authorization.encodeToken(accessToken),
     Some(Authorization.encodeToken(refreshToken)),
     Some(Set.concat(
+      Some("user"),
       Option.when(hasAdminPermissions)("admin")
     ).mkString(" ")),
     Some(Duration.between(Instant.now(), accessExpiration()).getSeconds()),
@@ -76,6 +78,7 @@ class Authorization (
     Duration.between(Instant.now(), accessExpiration()),
     Authorization.encodeToken(refreshToken),
     Set.concat(
+      Some("user"),
       Option.when(hasAdminPermissions)("admin")
     )
   )

+ 7 - 4
server/app/com/weEat/services/MongoDBService.scala

@@ -1,9 +1,8 @@
 package com.weEat.services
 
 import play.api.Configuration
-import org.mongodb.scala.{MongoClient,MongoDatabase,MongoCollection}
+import org.mongodb.scala.{MongoClient,MongoCollection}
 import org.mongodb.scala.bson.codecs.Macros._
-import org.mongodb.scala.bson.codecs._
 import org.bson.{BsonReader,BsonWriter}
 import org.bson.codecs.{Codec,DecoderContext,EncoderContext}
 import org.bson.codecs.configuration.{CodecProvider,CodecRegistry}
@@ -15,7 +14,7 @@ import javax.inject.{Inject,Singleton}
 import scala.reflect.ClassTag
 import com.weEat.migrations.{Migration,Metadata}
 import scala.concurrent.ExecutionContext
-import scala.util.{Try,Success,Failure}
+import scala.util.{Success,Failure}
 import gov.usda.nal.fdc.models.Nutrient
 
 @Singleton
@@ -43,10 +42,14 @@ class MongoDBService @Inject()(config: Configuration) {
             case (k, v) => (k.replace("$", "."), v)
           })
         }),
+      WrapperCodecProvider(classOf[FoodNodeId],
+        { n: RecipeNodeId => n },
+        { n: RecipeNodeId => n }
+      ),
       classOf[Metadata],
+      classOf[FoodNodeId],
       classOf[IngredientId],
       classOf[Ingredient],
-      classOf[FoodNodeId],
       classOf[User],
       classOf[Authorization],
       classOf[Nutrient],

+ 3 - 7
server/app/com/weEat/services/OAuth2Service.scala

@@ -4,21 +4,17 @@ import play.api.Configuration
 import codes.reactive.scalatime._
 import com.github.t3hnar.bcrypt._
 import com.weEat.models.{User,Authorization}
-import com.weEat.shared.models.RefreshRequest
 import java.time.Instant
 import javax.inject.{Inject,Singleton}
-import scala.concurrent.duration._
 import scala.concurrent.{ExecutionContext,Future}
-import scala.language.postfixOps
-import scala.util.{Success,Failure,Try}
+import scala.util.Success
 import scalaoauth2.provider._
-import org.mongodb.scala.model.Updates._
 import org.mongodb.scala.model.Filters._
-import org.mongodb.scala.bson.{BsonArray,BsonDocument,BsonValue}
 import org.mongodb.scala.bson.DefaultBsonTransformers
 import org.mongodb.scala.bson.conversions.Bson
-import java.util.Date
+import scala.language.implicitConversions
 
+// TOOD: Migrate to SecureSocial OAuth lib
 @Singleton
 class OAuth2Service @Inject()(db: MongoDBService, config: Configuration)
     extends AuthorizationHandler[User]

+ 1 - 1
server/app/views/main.scala.html

@@ -58,7 +58,7 @@
   @scalajs.html.scripts(
     "client",
     routes.Assets.versioned(_).toString,
-    name => getClass.getResource(s"/public/$name") != null
+    (name) => getClass.getResource(s"/public/$name") != null
   )
   @lastly
 </body>

+ 4 - 1
server/conf/application.conf

@@ -20,13 +20,16 @@ db.default.url="jdbc:h2:mem:users"
 
 oauth.dev = true
 
+# play.modules.enabled += "name.tflucke.mongoevolutions.EvolutionsModule"
+# mongodb.evolution.mongoCmd = "/usr/bin/mongo localhost:27017/recipes -u application -p 12345678"
+
 init.admin.email = "test@test.org"
 init.admin.password = "12345678"
 init.admin.fname = "admin"
 init.admin.lname = "istrator"
 
 mongo.user="application"
-mongo.password="test"
+mongo.password="12345678"
 mongo.ssl=false
 
 fdc.apikey="0Ky5r8VEcd0v9z4eJsNZoDNguKWzGZ0iCo4RpOJN"

+ 3 - 3
server/conf/routes

@@ -3,9 +3,7 @@
 # https://www.playframework.com/documentation/latest/ScalaRouting
 # ~~~~
 
-GET   /               @controllers.Default.redirect(to = "/view")
-
-GET   /view           com.weEat.controllers.ViewController.loader()
+GET   /               @controllers.Default.redirect(to = "/foodsearch?")
 
 GET   /initialize     com.weEat.controllers.ViewController.initalizer()
 POST  /initialize     com.weEat.controllers.ViewController.initalize()
@@ -95,3 +93,5 @@ GET   /assets/*file   controllers.Assets.versioned(path="/public", file: Asset)
 
 # Forward Webjar requests to the webjar routes
 ->      /webjars                            webjars.Routes
+
+GET   /:other         com.weEat.controllers.ViewController.loader(other)

+ 36 - 0
shared/js/src/main/scala/com/weEat/shared/SecureStorage.scala

@@ -0,0 +1,36 @@
+package com.weEat.shared
+
+import scala.scalajs.js
+import scala.scalajs.js.annotation.JSGlobal
+
+@js.native
+protected trait JsStorage extends js.Object {
+  def setItem(key: String, value: String): Unit = js.native
+  def getItem(key: String): String = js.native
+  def removeItem(key: String): Unit = js.native
+  def clear(): Unit = js.native
+}
+
+trait Storage {
+  protected val native: JsStorage
+
+  def set(key: String, value: String): Storage = {
+    native.setItem(key, value)
+    this
+  }
+  def get(key: String): Option[String] = Option(native.getItem(key))
+  def remove(key: String): Storage = {
+    native.removeItem(key)
+    this
+  }
+  def clear(): Storage = {
+    native.clear()
+    this
+  }
+}
+
+object SecureStorage extends Storage {
+  @js.native
+  @JSGlobal("window.localStorage")
+  protected object native extends JsStorage {}
+}

+ 17 - 0
shared/js/src/main/scala/com/weEat/shared/package.scala

@@ -0,0 +1,17 @@
+package com.weEat
+
+import scala.scalajs.js
+import scala.concurrent.duration.FiniteDuration
+
+package object shared {
+  val ctx = org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global
+
+  @js.native
+  @js.annotation.JSGlobal("btoa")
+  def base64Encode(str: String): String = js.native
+
+  def setTimeout(time: FiniteDuration)(callback: => Unit): Unit = {
+    js.timers.setTimeout(time)(callback)
+    ()
+  }
+}

+ 35 - 0
shared/jvm/src/main/scala/com/weEat/shared/SecureStorage.scala

@@ -0,0 +1,35 @@
+package com.weEat.shared
+
+protected trait NativeStorage {
+  def setItem(key: String, value: String): Unit
+  def getItem(key: String): String
+  def removeItem(key: String): Unit
+  def clear(): Unit
+}
+
+trait Storage {
+  protected val native: NativeStorage
+
+  def set(key: String, value: String): Storage = {
+    native.setItem(key, value)
+    this
+  }
+  def get(key: String): Option[String] = Option(native.getItem(key))
+  def remove(key: String): Storage = {
+    native.removeItem(key)
+    this
+  }
+  def clear(): Storage = {
+    native.clear()
+    this
+  }
+}
+
+object SecureStorage extends Storage {
+  protected object native extends NativeStorage {
+    def setItem(key: String, value: String): Unit = ???
+    def getItem(key: String): String = ???
+    def removeItem(key: String): Unit = ???
+    def clear(): Unit = ???
+  }
+}

+ 11 - 0
shared/jvm/src/main/scala/com/weEat/shared/package.scala

@@ -0,0 +1,11 @@
+package com.weEat
+
+import scala.concurrent.duration.FiniteDuration
+
+package object shared {
+  val ctx = scala.concurrent.ExecutionContext.Implicits.global
+
+  def base64Encode(str: String): String = ???
+
+  def setTimeout(time: FiniteDuration)(callback: => Unit): Unit = ???
+}

+ 114 - 0
shared/shared/src/main/scala/com/weEat/shared/OAuthManager.scala

@@ -0,0 +1,114 @@
+package com.weEat.shared
+
+import com.weEat.controllers.UserController
+import com.weEat.shared.models.PasswordRequest
+import com.weEat.shared.models.RefreshRequest
+import com.weEat.shared.models.UserAuthorization
+import com.weEat.shared.models.UserRegistration
+import com.tflucke.webroutes.Headers
+import scala.concurrent.ExecutionContext
+
+import java.time.Instant
+import scala.concurrent.Future
+
+object OAuthManager {
+  def authHeader = SecureStorage.get("access-token").map({ (token) =>
+    ("Authorization" -> token)
+  })
+
+  def signup(reg: UserRegistration)(implicit
+    headers: Headers,
+    c: ExecutionContext
+  ): Future[Unit] =
+    UserController.registerUser()(reg) map(_loginComplete(reg.email))
+
+  def login(headers: Headers = Headers.empty)(loginPair: (String, String))(
+    implicit c: ExecutionContext
+  ): Future[Unit] = {
+    implicit val authedHeaders = headers + (
+      "Authorization" ->
+        ("Basic " + base64Encode("%s:%s".format(loginPair._1, loginPair._2)))
+    )
+    UserController.accessToken()(PasswordRequest())(
+      implicitly,
+      authedHeaders
+    ).map(_loginComplete(loginPair._1))
+  }
+
+  def logout()(implicit headers: Headers, c: ExecutionContext = ctx): Future[Unit] =
+    SecureStorage.get("username").flatMap({ (user) =>
+      SecureStorage.get("refresh-token").map({ (refresh: String) =>
+        authHeader.map { (auth) =>
+          UserController.revokeAccessToken()(RefreshRequest(user, refresh))(
+            implicitly,
+            headers + auth
+          )
+        } getOrElse(
+          Future.failed(new IllegalStateException("No login information."))
+        )
+      })
+    }).getOrElse(Future.failed(
+      new IllegalStateException("No login information.")
+    )).map({ (_) => _clear() })
+
+  def refreshToken()(implicit
+    headers: Headers,
+    c: ExecutionContext
+  ): Future[Unit] = {
+    SecureStorage.get("username").flatMap({ (user) =>
+      SecureStorage.get("refresh-token").map({ (refresh) =>
+        UserController.accessToken()(RefreshRequest(user, refresh))(
+          implicitly,
+          headers = headers - "Authorization"
+        ).map(_loginComplete(user)).recover({
+          case _ => _clear()
+        }).map((_) => ())
+      })
+    }).getOrElse(Future.failed(new IllegalStateException("No token present")))
+  }
+
+  private def _loginComplete(user: String)(auth: UserAuthorization)(implicit
+    headers: Headers,
+    c: ExecutionContext
+  ) = {
+    SecureStorage.set("access-token", s"${auth.tokenType} ${auth.accessToken}")
+    SecureStorage.set("username", user)
+    SecureStorage.set("refresh-token", auth.refreshToken)
+    SecureStorage.set("scope", auth.scope.mkString("\n"))
+    SecureStorage.set("expires",
+      s"${Instant.now.toEpochMilli + auth.expiresIn.toMillis}"
+    )
+    setTimeout(auth.expiresIn)(refreshToken())
+    observers.foreach(_())
+  }
+
+  def currentScope: Set[String] = SecureStorage
+    .get("scope")
+    .getOrElse("")
+    .split("\n")
+    .toSet
+
+  private def _clear() = {
+    SecureStorage
+      .remove("username")
+      .remove("refresh-token")
+      .remove("access-token")
+      .remove("expires")
+      .remove("scope")
+    observers.foreach(_())
+  }
+
+  def isExpired: Boolean = SecureStorage
+    .get("expires")
+    .map(_.toLong < Instant.now.toEpochMilli)
+    .getOrElse(true)
+
+  def refreshIfExpired()(implicit
+    headers: Headers,
+    c: ExecutionContext
+  ): Future[Unit] = if (isExpired) refreshToken() else Future.unit
+
+
+  private var observers: Seq[() => Unit] = Nil
+  def addObserver(observer: () => Unit) = observers = observer +: observers
+}

+ 22 - 20
shared/shared/src/main/scala/com/weEat/shared/models/FoodNode.scala

@@ -1,21 +1,18 @@
 package com.weEat.shared.models
 
 import UnitType._
-import scala.util.{Try,Success,Failure}
+import scala.util.{Success,Failure}
 import scala.concurrent.Future
-import com.weEat.shared.exceptions._
 import play.api.libs.json.Json
 import com.weEat.shared.models.IdentifierHelper._
 import com.weEat.controllers.{FoodController,USDAController,UserController}
 import play.api.libs.json.{JsonConfiguration,JsonNaming}
-import gov.usda.nal.fdc.models.{FoodItem,SearchResultFood,FullFoodItem,FoodPortion}
-import gov.usda.nal.fdc.models.DataType._
+import gov.usda.nal.fdc.models.{FoodItem,SearchResultFood}
+import scala.language.implicitConversions
 
 sealed trait FoodNode {
   import FoodNodeId.NodeType._
 
-  implicit val ctx = scala.concurrent.ExecutionContext.global
-
   def name: String
   def defaultUnitType: UnitType = MASS
   def density: Option[Float] = None
@@ -29,6 +26,8 @@ sealed trait FoodNode {
 }
 
 sealed trait FoodNodeId extends FoodNode {
+  implicit val ctx = com.weEat.shared.ctx
+
   val _id: Identifier
   val uid: Identifier
 
@@ -42,7 +41,7 @@ case class Ingredient(
   val amount: Float,
   val unit: MeasureUnit
 ) {
-  implicit val ctx = scala.concurrent.ExecutionContext.global
+  implicit val ctx = com.weEat.shared.ctx
 
   lazy val food = id match {
     case Ingredient.FoodNodeId(id) => FoodController.get(id.toString)()
@@ -108,6 +107,8 @@ object Ingredient {
 }
 
 sealed trait RecipeNode extends FoodNode {
+  implicit val ctx = com.weEat.shared.ctx
+
   val nodeType = FoodNodeId.NodeType.RECIPE
 
   def stdQties: Float
@@ -115,7 +116,10 @@ sealed trait RecipeNode extends FoodNode {
   def defaultUnitType: UnitType
   def ingredients: Seq[Ingredient]
   def steps: Seq[String]
-  
+
+  def numServings: Float = stdQties / stdQtiesPServing
+  def servingSizeInGrams: Float = stdQtiesPServing * 100
+
   def withId(id: Identifier, uid: Identifier) = RecipeNodeId(
     id,
     uid,
@@ -144,7 +148,11 @@ sealed trait RecipeNode extends FoodNode {
     ).flatten
   }).fold(Future.successful(0.0f))({ case (a, b) =>
     a.zipWith(b) { case (x, y) => x + y }
-  }).map { _ / stdQties / 100.0f }
+  }).map { _ / 100.0f }
+
+  def nutrientPServing(num: String) = nutrient(num).map { _ / numServings }
+
+  def nutrientPStdQty(num: String) = nutrient(num).map { _ / stdQties }
 }
 
 case class RecipeNodeNoId(
@@ -169,7 +177,9 @@ case class RecipeNodeId(
   val steps: Seq[String],
   override val density: Option[Float],
   override val massPerUnit: Option[Float]
-) extends RecipeNode with FoodNodeId
+) extends RecipeNode with FoodNodeId {
+  implicit override val ctx = com.weEat.shared.ctx
+}
 
 object RecipeNodeNoId {
   implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
@@ -288,15 +298,12 @@ object USDANodeNoId {
     case SampleFoodItem(id, _, desc, _, _, _) => ???
   }
 
-  import play.api.libs.functional.syntax._
-
   implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
   implicit val idFmt = com.weEat.shared.models.IdentifierHelper.fmt
   implicit val fmt = Json.using[Json.WithDefaultValues].format[USDANodeNoId]
 }
 
 object USDANodeId {
-  import play.api.libs.functional.syntax._
 
   implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
   implicit val idFmt = com.weEat.shared.models.IdentifierHelper.fmt
@@ -306,9 +313,7 @@ object USDANodeId {
 object FoodNodeId {
   val collectionName = "foods"
 
-  import play.api.libs.functional.syntax._
   import play.api.libs.json._
-  import java.text.ParseException
 
   object NodeType extends Enumeration {
     type NodeType = Value
@@ -325,7 +330,7 @@ object FoodNodeId {
     (requ match {
       case node: USDANodeId => Json.toJson(node)
       case node: RecipeNodeId => Json.toJson(node)
-    }).asInstanceOf[JsObject] + ("node_type", Json.toJson(requ.nodeType))
+    }).asInstanceOf[JsObject] + (("node_type", Json.toJson(requ.nodeType)))
   )
 
   implicit val requestReads = Reads[FoodNodeId](json =>
@@ -337,18 +342,15 @@ object FoodNodeId {
 }
 
 object FoodNode {
-  import FoodNodeId.NodeType
   import FoodNodeId.NodeType._
-  import play.api.libs.functional.syntax._
   import play.api.libs.json._
-  import java.text.ParseException
 
   implicit val requestWrites = Writes[FoodNode](requ =>
     (requ match {
       case node: USDANodeNoId => Json.toJson(node)
       case node: RecipeNodeNoId => Json.toJson(node)
       case node: FoodNodeId => Json.toJson(node)
-    }).asInstanceOf[JsObject] + ("node_type", Json.toJson(requ.nodeType))
+    }).asInstanceOf[JsObject] + (("node_type", Json.toJson(requ.nodeType)))
   )
 
   implicit val requestReads = Reads[FoodNode](json =>

+ 1 - 2
shared/shared/src/main/scala/com/weEat/shared/models/GrantRequest.scala

@@ -59,14 +59,13 @@ object RefreshRequest {
 }
 
 object GrantRequest {
-  import play.api.libs.functional.syntax._
   import play.api.libs.json.{JsObject,Reads,Writes}
 
   implicit val requestWrites = Writes[GrantRequest](requ =>
     (requ match {
       case pass: PasswordRequest => Json.toJson(pass)
       case refr: RefreshRequest => Json.toJson(refr)
-    }).asInstanceOf[JsObject] + ("grant_type", Json.toJson(requ.grantType))
+    }).asInstanceOf[JsObject] + (("grant_type", Json.toJson(requ.grantType)))
   )
 
   implicit val requestReads = Reads[GrantRequest](json =>

+ 9 - 4
shared/shared/src/main/scala/com/weEat/shared/models/MeasureUnit.scala

@@ -3,6 +3,11 @@ package com.weEat.shared.models
 import scala.util.{Try,Success,Failure}
 import com.weEat.shared.exceptions._
 
+class ParsedMeasure(
+  val unit: MeasureUnit,
+  val raw: String
+)
+
 sealed trait MeasureUnit {
   def typ: UnitType.UnitType
   /* defaultUnit/thisUnit = conversionRatio */
@@ -87,9 +92,10 @@ object MeasureUnit {
     units.maxByOption(u => u.lNames.maxByOption(matchFn).flatMap(matchFn))
   }
 
-  def closestPair[T](seq: Seq[(T, MeasureUnit, String)]) = seq.maxByOption(p =>
-    p._2.lNames.flatMap(matchDegree(p._3.toLowerCase)(_)).maxOption
-  )
+  def closestPair[T <: ParsedMeasure](seq: Seq[T]): Option[T] =
+    seq.maxByOption((p) =>
+      p.unit.lNames.flatMap(matchDegree(p.raw.toLowerCase)(_)).maxOption
+    )
 
   import play.api.libs.json._
   implicit val writes = Writes[MeasureUnit](unit => new JsString(unit.toString))
@@ -147,7 +153,6 @@ object UnitType {
     def standardQuanity: Float = UnitType.standardQuanity(typ)
   }
 
-  import play.api.libs.json.{Json,Reads,Writes}
   import julienrf.json.derived
 
   implicit val fmt = derived.oformat[UnitType.UnitType]()

+ 1 - 1
shared/shared/src/main/scala/com/weEat/shared/models/User.scala

@@ -1,6 +1,6 @@
 package com.weEat.shared.models
 
-import play.api.libs.json.{Format,Json}
+import play.api.libs.json.Json
 import com.weEat.shared.models.IdentifierHelper._
 
 /* Basic User information */

+ 63 - 0
webClient/src/main/scala/com/weEat/Main.scala

@@ -0,0 +1,63 @@
+package com.weEat
+
+import com.raquo.laminar.api.L._
+
+import com.tflucke.webroutes.Headers
+import com.weEat.modules.Navbar
+import com.weEat.shared.OAuthManager
+import com.weEat.view.View
+
+import org.scalajs.dom.document
+import org.scalajs.dom.{HTMLInputElement,HTMLAnchorElement,HTMLUListElement}
+
+object Main {
+  implicit def headers = Headers(Seq(
+    csrfHeader,
+    OAuthManager.authHeader
+  ).flatten.toMap)
+
+  implicit val ctx = com.weEat.shared.ctx
+
+  /* Get the CSRF token embedded in the HTML page.  This token will implicitly
+   * be passed to Rest RPC.
+   */
+  val csrfSelector = "input[name=\"csrfToken\"]"
+  def csrfHeader = Option(document.querySelector(csrfSelector)).map({ x =>
+    ("Csrf-Token" -> x.asInstanceOf[HTMLInputElement].value)
+  })
+
+  def main(args: Array[String]): Unit = {
+    OAuthManager.refreshToken()
+
+    val navbar = Navbar()
+
+    renderOnDomContentLoaded(document.getElementById("navbarNav"), navbar.render)
+    renderOnDomContentLoaded(document.getElementById("content"), div(
+      child <-- View.splitter.signal
+    ))
+
+    def _isLoggedIn = !OAuthManager.isExpired
+    OAuthManager.addObserver({ () =>
+      document.getElementById("login-btns").asInstanceOf[HTMLUListElement]
+        .style.display = if (_isLoggedIn) "none" else ""
+      document.getElementById("logout-btns").asInstanceOf[HTMLUListElement]
+        .style.display = if (_isLoggedIn) "" else "none"
+    })
+
+
+    windowEvents(_.onLoad).foreach { (_) =>
+      document.getElementById("btn-login").asInstanceOf[HTMLAnchorElement]
+        .onclick = { (_) => models.LoginVar()
+          .showPrompt()
+          .map(OAuthManager.login(headers)(_)(implicitly))
+        }
+      document.getElementById("btn-signup").asInstanceOf[HTMLAnchorElement]
+        .onclick = { (_) => models.UserRegistrationVar()
+          .showPrompt()
+          .map(OAuthManager.signup(_)(implicitly, implicitly))
+        }
+      document.getElementById("btn-logout").asInstanceOf[HTMLAnchorElement]
+        .onclick = { (_) => OAuthManager.logout() }
+    }(unsafeWindowOwner)
+  }
+}

+ 40 - 0
webClient/src/main/scala/com/weEat/models/LoginVar.scala

@@ -0,0 +1,40 @@
+package com.weEat.models
+
+import com.raquo.laminar.api.L._
+
+import scala.util.{Try,Success}
+
+case class LoginVar() extends VarRepresentationOf[(String, String)] {
+
+  val username  = Var("")
+  val passwd = Var("")
+
+  val newInstance: Signal[Try[(String, String)]] =
+    username.signal.combineWithFn(passwd.signal) {
+      case (username, passwd) => Success((username, passwd))
+    }
+
+  protected def renderEditPane(): Node =
+    form(cls := "text-right", idAttr := "view-login",
+      div(cls := "alert alert-danger",
+        display := "none",
+        textAlign := "left"
+      ),
+      div(cls := "input-group",
+        input(idAttr := "email",
+          tpe := "text",
+          cls := "form-control",
+          placeholder := "Email",
+          onInput.mapToValue --> username
+        )
+      ),
+      div(cls := "input-group mb-3",
+        input(idAttr := "password",
+          tpe := "password",
+          cls := "form-control",
+          placeholder := "Password",
+          onInput.mapToValue --> passwd
+        )
+      )
+    )
+}

+ 11 - 0
webClient/src/main/scala/com/weEat/models/Nutrient.scala

@@ -0,0 +1,11 @@
+package com.weEat.models
+
+import com.raquo.laminar.api.L._
+import com.weEat.controllers.USDAController
+
+object Nutrient {
+
+  implicit val ctx = com.weEat.shared.ctx
+
+  lazy val nutrients = Signal.fromFuture(USDAController.getNutrients()())
+}

+ 75 - 0
webClient/src/main/scala/com/weEat/models/UserRegistrationVar.scala

@@ -0,0 +1,75 @@
+package com.weEat.models
+
+import com.raquo.laminar.api.L._
+
+import scala.util.{Try,Success,Failure}
+
+import com.weEat.shared.models.UserRegistration
+
+case class UserRegistrationVar()
+    extends VarRepresentationOf[UserRegistration] {
+
+  val username  = Var("")
+  val passwd = Var("")
+  val passwd2 = Var("")
+  val fname = Var("")
+  val lname = Var("")
+
+  val newInstance: Signal[Try[UserRegistration]] =
+    username.signal.combineWithFn(
+      passwd.signal,
+      passwd2.signal,
+      fname.signal,
+      lname.signal
+    ) {
+      case (_, p1, p2, _, _) if p1 != p2 => Failure(PasswordNotMatchException)
+      case (username, passwd, _, fname, lname) => Success(
+        UserRegistration(fname, lname, username, passwd)
+      )
+    }
+
+  protected def renderEditPane(): Node =
+    form(cls := "text-right", idAttr := "view-register",
+      div(cls := "alert alert-danger", display := "none", textAlign := "left"),
+      div(cls := "input-group mb-3",
+        input(idAttr := "email",
+          tpe := "text",
+          cls := "form-control",
+          placeholder := "Email",
+          onInput.mapToValue --> username
+        )
+      ),
+      div(cls := "input-group",
+        input(idAttr := "password",
+          tpe := "password",
+          cls := "form-control",
+          placeholder := "Password",
+          onInput.mapToValue --> passwd
+        )
+      ),
+      div(cls := "input-group mb-3",
+        input(idAttr := "password2",
+          tpe := "password",
+          cls := "form-control",
+          placeholder := "Repeat Password",
+          onInput.mapToValue --> passwd2
+        )
+      ),
+      div(cls := "input-group mb-3",
+        input(idAttr := "fname",
+          tpe := "text",
+          cls := "form-control",
+          placeholder := "First Name",
+          onInput.mapToValue --> fname
+        ),
+        input(idAttr := "lname",
+          tpe := "text",
+          cls := "form-control",
+          placeholder := "Last Name",
+          onInput.mapToValue --> lname
+        )
+      )
+    )
+}
+
+object PasswordNotMatchException extends RuntimeException("Passwords do not match.")

+ 88 - 0
webClient/src/main/scala/com/weEat/models/VarRepresenationOf.scala

@@ -0,0 +1,88 @@
+package com.weEat.models
+
+import com.raquo.laminar.api.L._
+import com.raquo.airstream.ownership.ManualOwner
+import com.weEat.modules.Overlay
+
+import scala.concurrent.Future
+import scala.util.Failure
+import scala.util.Success
+import scala.util.Try
+
+trait VarRepresentationOf[T] {
+  val newInstance: Signal[Try[T]]
+  protected def renderEditPane(): Node
+
+  final def showPrompt(): Future[T] = {
+    implicit val owner = new ManualOwner()
+
+    Overlay.confirm(
+      div(cls := "form-group",
+        renderEditPane(),
+        onUnmountCallback((_) => owner.killSubscriptions())
+      ),
+      newInstance.map({ _.isSuccess })
+    ) { () => newInstance.observe.now().get }
+  }
+
+  final def render() = div(cls := "form-group", renderEditPane())
+
+  // Helper Methods
+  protected def _lockedInput(col: Short, lvar: Var[String], t: String = "text", labe: String = "") = {
+    val id = s"input-${labe.replaceAll(raw"[^\d\w_-]", "-")}"
+    div(cls := s"form-group col-$col",
+      div(cls := "input-group",
+        div(cls := "input-group-prepend",
+          label(cls := "input-group-text", forId := id, labe)
+        ),
+        input(idAttr := id, typ := t,
+          cls := "form-control",
+          value <-- lvar,
+          onInput.mapToValue --> lvar
+        )
+      )
+    )
+  }
+
+  protected def _tryInput[U](col: Short, lvar: Var[U], t: String = "text", labe: String = "")(in: (U) => String)(out: (String) => Try[U]) = {
+    val id = s"input-${labe.replaceAll(raw"[^\d\w_-]", "-")}"
+    div(cls := s"form-group col-$col",
+      div(cls := "input-group",
+        div(cls := "input-group-prepend",
+          label(cls := "input-group-text", forId := id, labe)
+        ),
+        input(typ := t,
+          idAttr := id,
+          cls <-- lvar.signal.recoverToTry.map({
+            case Success(_) => s"form-control is-valid"
+            case Failure(_) => s"form-control is-invalid"
+          }),
+          value <-- lvar.signal.recoverIgnoreErrors.map(in),
+          onInput.mapToValue --> lvar.tryUpdater((_, ne) => out(ne))
+        ),
+        div(cls := "invalid-feedback", child <-- lvar.signal.recoverToTry.map({
+          case Success(_) => ""
+          case Failure(e) => e.getMessage()
+        }))
+      )
+    )
+  }
+
+  protected def _lockedSelect(col: Short, enumObj: Enumeration, labe: String = "")
+    (lvar: Var[enumObj.Value])
+    (renderFn: (enumObj.Value) => String = _.toString) = {
+    val id = s"select-${labe.replaceAll(raw"[^\d\w_-]", "-")}"
+    div(cls := s"form-group col-$col",
+      label(forId := id, labe),
+      select(idAttr := id,
+        cls := "form-control",
+        onChange.mapToValue.map(enumObj.withName(_)) --> lvar,
+        enumObj.values.toSeq.map((v) => option(
+          value := v.toString,
+          selected <-- lvar.signal.map(_ == v),
+          renderFn(v)
+        ))
+      )
+    )
+  }
+}

+ 7 - 0
webClient/src/main/scala/com/weEat/modules/Module.scala

@@ -0,0 +1,7 @@
+package com.weEat.modules
+
+import com.raquo.laminar.api.L._
+
+trait Module {
+  val render: Node
+}

+ 27 - 0
webClient/src/main/scala/com/weEat/modules/Navbar.scala

@@ -0,0 +1,27 @@
+package com.weEat.modules
+
+import com.raquo.laminar.api.L._
+import io.laminext.syntax.core._
+import com.weEat.view.View
+
+case class Navbar() extends Module {
+  val render = ul(cls := "navbar-nav mr-auto mt-2 mt-lg-0",
+    children <-- View.authedIndex.split(_.tag) {
+      case (tag, _, viewStream) => li(
+        cls <-- View.router.currentPageSignal.map(_.t)
+          .valueIs(tag)
+          .switch("nav-item active", "nav-item"),
+        a(cls := "nav-link",
+          href := "#",
+          onMountBind({ (ctx) =>
+            import ctx.owner
+            onClick --> { (_) =>
+              View.router.pushState(viewStream.observe.now().defaultPage)
+            }
+          }),
+          child.text <-- viewStream.map(_.navName)
+        )
+      )
+    },
+  )
+}

+ 362 - 0
webClient/src/main/scala/com/weEat/modules/NutritionPane.scala

@@ -0,0 +1,362 @@
+package com.weEat.modules
+
+import com.raquo.laminar.api.L._
+import com.weEat.modules._
+import com.weEat.shared.models._
+import scala.concurrent.Future
+
+case class NutritionPane(val food: Signal[RecipeNode])
+    extends Module {
+  implicit val ctx = com.weEat.shared.ctx
+
+  private val _daily = Map(
+    "204" -> 78f,   // Total Fat
+    "606" -> 20f,   // Saturated Fat
+    "601" -> 300f,  // Cholesterol
+    "605" -> 2f,    // Trans fats
+    "307" -> 2300f, // Sodium
+    "205" -> 275f,  // Total Carbohydrates
+    "291" -> 28f,   // Dietary Fiber
+    "320" -> 800f,  // Vitamin A (900 M, 700 F)
+    "415" -> (1.15f + 1.2f + 15f + 1.3f + 2.4f + 5f), // Vitamin B
+    "401" -> 88f,   // Vitamin C (90 M, 75 F)
+    "324" -> 15f,   // Vitamin D
+    "301" -> 1300f, // Calcium
+    "303" -> 13f,   // Iron (8 M, 18 F)
+    "306" -> 4700f, // Potassium
+    "432" -> 400f   // Folate
+  )
+  private val _caloriesInFat = 8.84f
+
+  private def _show(decimals: Int)(getNutrient: RecipeNode => Future[Float]) =
+    food.flatMap((fn) => Signal.fromFuture(getNutrient(fn))).map {
+      case Some(v) => s"%.${decimals}f".format(v)
+      case None => ""
+    }
+
+  private def _showDaily(nutrient: String): Signal[Node] =
+    (_show(0) { _.nutrientPServing(nutrient).map(_ / _daily(nutrient) * 100.0f) })
+      .map(_ + "%")
+
+  private def _showDailyLine(
+    title: Node,
+    dec: Int,
+    unit: String,
+    nutrient: String
+  ): Node = div(cls := "line",
+    div(cls := "nutrLabel",
+      s"Total ",
+      title,
+      " ",
+      div(cls := "weight",
+        child.text <-- _show(dec) { _.nutrientPServing(nutrient) },
+        unit
+      )
+    ),
+    div(cls := "dv",
+      child <-- _showDaily(nutrient)
+    )
+  )
+
+  private def _showDailyLine(
+    title: String,
+    dec: Int,
+    unit: String,
+    nutrient: String
+  ): Node = _showDailyLine( span(title), dec, unit, nutrient)
+
+  private val tdStyle = Seq(
+    color := "black",
+    fontFamily := "'Arial Black','Helvetica Bold',sans-serif",
+    fontSize := "8pt",
+    padding := "0"
+  )
+
+  private val headerStyle = Seq(
+    color := "black",
+    fontFamily := "'Arial Black','Helvetica Bold',sans-serif",
+    fontSize := "28pt",
+    whiteSpace := "nowrap"
+  )
+
+  private val nutrLabelStyle = Seq(
+    float := "left",
+    fontFamily := "'Arial Black','Helvetica Bold',sans-serif"
+  )
+
+  private val servingStyle = Seq(
+    fontFamily := "'Arial','Helvetica',sans-serif",
+    fontSize := "8pt",
+    textAlign := "center"
+  )
+
+  private val weightStyle = Seq(
+    fontFamily := "'Arial','Helvetica',sans-serif",
+    paddingLeft := "1px",
+    display := "inline"
+  )
+
+  private val dvStyle = Seq(
+    fontFamily := "'Arial Black','Helvetica Bold',sans-serif",
+    float := "right",
+    display := "inline"
+  )
+
+  private val lineStyle = Seq(
+    borderTop := "1px solid black"
+  )
+
+  private val highlightedStyle = Seq(
+    border := "1px dotted grey",
+    padding := "2px"
+  )
+
+  private val vitaminTdStyle = Seq(
+    fontFamily := "'Arial','Helvetica',sans-serif",
+    whiteSpace := "nowrap",
+    width := "33%"
+  )
+
+  private val nutrLabelLightStyle = Seq(
+    fontFamily := "'Arial','Helvetica',sans-serif",
+    float := "left"
+  )
+
+  val render = div(
+    styleTag(tpe := "text/css","""#nutritionfacts {
+        background-color:white; 
+        border:1px solid black; 
+        padding:3px;
+        padding-right:5px;
+        width:244px; 
+    }
+    #nutritionfacts td { 
+        color:black; 
+        font-family:'Arial Black','Helvetica Bold',sans-serif; 
+        font-size:8pt; 
+        padding:0;
+    }
+    #nutritionfacts td.header { 
+        font-family:'Arial Black','Helvetica Bold',sans-serif; 
+        font-size:28px; 
+        white-space:nowrap; 
+    }        
+    #nutritionfacts div.nutrLabel { 
+        float:left; 
+        font-family:'Arial Black','Helvetica Bold',sans-serif; 
+    }
+    #nutritionfacts div.serving { 
+        font-family:Arial,Helvetica,sans-serif; 
+        font-size:8pt; 
+        text-align:center; 
+    }
+    #nutritionfacts div.weight { 
+        display:inline; 
+        font-family:Arial,Helvetica,sans-serif; 
+        padding-left:1px; 
+    }
+    #nutritionfacts div.dv { 
+        display:inline; 
+        float:right; 
+        font-family:'Arial Black','Helvetica Bold',sans-serif; 
+    }
+    #nutritionfacts table.vitamins td {  
+        font-family:Arial,Helvetica,sans-serif; 
+        white-space:nowrap; 
+        width:33%; 
+    }
+    #nutritionfacts div.line { 
+        border-top:1px solid black; 
+    }
+    #nutritionfacts div.nutrLabellight { 
+        float:left; 
+        font-family:Arial,Helvetica,sans-serif; 
+    }
+    #nutritionfacts .highlighted {
+        border:1px dotted grey;
+        padding:2px;
+    }"""),
+    div(idAttr := "nutritionfacts",
+      table(styleAttr := "width: 100%; border-spacing: 0;", //cellPadding := "0",
+        tbody(
+          tr(
+            td(styleAttr := "text-align: center;",
+              cls := "header",
+              "Nutrition Facts"
+            )
+          ),
+          tr(
+            td(
+              div(cls := "serving","Per",
+                span(cls := "highlighted",
+                  child.text <-- _show(1)((r) =>
+                    Future.successful(r.servingSizeInGrams)
+                  ),
+                  "g"
+                ),
+                "Serving Size"
+              )
+            )
+          ),
+          tr(styleAttr := "height: 7px",
+            td(styleAttr := "background-color: #000000;")
+          ),
+          tr(
+            td(styleAttr := "font-size: 7pt",
+              div(cls := "line", "Amount Per Serving")
+            )
+          ),
+          tr(
+            td(
+              div(cls := "line",
+                div(cls := "nutrLabel",
+                  "Calories ",
+                  div(cls := "weight",
+                    child.text <-- _show(0) { _.nutrientPServing("208") }
+                  )
+                ),
+                div(styleAttr := "padding-top: 1px; float: right;",
+                  cls := "nutrLabellight",
+                  "Calories from Fat ",
+                  div(cls := "weight",
+                    child.text <-- _show(0) {
+                      _.nutrientPServing("204").map(_*_caloriesInFat)
+                    }
+                  )
+                )
+              )
+            )
+          ),
+          tr(
+            td(
+              div(cls := "line",
+                div(cls := "dvnutrLabel",
+                  "% Daily Value",
+                  sup("*")
+                )
+              )
+            )
+          ),
+          tr(
+            td(
+              _showDailyLine("Total Fat", 0, "g", "204")
+            )
+          ),
+          tr(
+            td(cls := "indent",
+              _showDailyLine("Saturated Fat", 1, "g", "606")
+            )
+          ),
+          tr(
+            td(cls := "indent",
+              _showDailyLine(span(i("Trans"), " Fat"), 1, "g", "605")
+            )
+          ),
+          tr(
+            td(
+              _showDailyLine("Cholesterol", 1, "mg", "601")
+            )
+          ),
+          tr(
+            td(
+              _showDailyLine("Sodium", 1, "mg", "307")
+            )
+          ),
+          tr(
+            td(
+              _showDailyLine("Total Carbohydrates", 1, "g", "205")
+            )
+          ),
+          tr(
+            td(cls := "indent",
+              _showDailyLine("Dietary Fiber", 1, "g", "291")
+            )
+          ),
+          tr(
+            td(cls := "indent",
+              div(cls := "line",
+                div(cls := "nutrLabellight",
+                  "Sugars ",
+                  div(weightStyle,
+                    child.text <-- _show(1) { _.nutrientPServing("269") },
+                    "g"
+                  )
+                )
+              )
+            )
+          ),
+          tr(
+            td(
+              div(cls := "line",
+                div(cls := "nutrLabel",
+                  "Protein ",
+                  div(weightStyle,
+                    child.text <-- _show(1) { _.nutrientPServing("203") },
+                    "g"
+                  )
+                )
+              )
+            )
+          ),
+          tr(styleAttr := "height: 7px",
+            td(styleAttr := "background-color: #000000;")
+          ),
+          tr(
+            td(
+              table(styleAttr := "border-style: none; border-spacing: 0;",
+                cls := "vitamins",
+                tbody(
+                  tr(
+                    td("Vitamin A   ", child <-- _showDaily("320")),
+                    td(styleAttr := "text-align: center;","•"),
+                    td(styleAttr := "text-align: right;",
+                      "Calcium   ",
+                      child <-- _showDaily("301")
+                    )
+                  ),
+                  tr(
+                    td("Vitamin B   ", child <-- _showDaily("415")),
+                    td(styleAttr := "text-align: center;","•"),
+                    td(styleAttr := "text-align: right;",
+                      "Iron   ",
+                      child <-- _showDaily("303")
+                    )
+                  ),
+                  tr(
+                    td("Vitamin C   ", child <-- _showDaily("401")),
+                    td(styleAttr := "text-align: center;","•"),
+                    td(styleAttr := "text-align: right;",
+                      "Potassium   ",
+                      child <-- _showDaily("306")
+                    )
+                  ),
+                  tr(
+                    td("Vitamin D   ", child <-- _showDaily("324")),
+                    td(styleAttr := "text-align: center;","•"),
+                    td(styleAttr := "text-align: right;",
+                      "Folate   ",
+                      child <-- _showDaily("432")
+                    )
+                  )
+                )
+              )
+            )
+          ),
+          tr(
+            td(
+              div(cls := "line",
+                div(cls := "nutrLabellight",
+                  "* Based on a regular 2000 calorie diet",
+                  br(),
+                  br(),
+                  i("""Nutritional details are an estimate and should only be used
+                       as a guide for approximation.""")
+                )
+              )
+            )
+          )
+        )
+      )
+    )
+  )
+}

+ 147 - 0
webClient/src/main/scala/com/weEat/modules/Overlay.scala

@@ -0,0 +1,147 @@
+package com.weEat.modules
+
+import com.raquo.laminar.api.L._
+import org.scalajs.dom.document
+
+import scala.concurrent.ExecutionContext
+import scala.concurrent.Future
+import scala.concurrent.Promise
+import scala.util.Try
+
+object Overlay {
+  implicit val ctx = org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global
+
+  object Closed extends Throwable()
+
+  private class _PromiseWrapper[T](promise: Promise[T], root: RootNode)(implicit ctx: ExecutionContext) extends Promise[T] {
+    lazy val future = {
+      val result = promise.future
+      result.onComplete((_) => root.unmount())
+      result
+    }
+
+    def isCompleted = promise.isCompleted
+
+    def tryComplete(result: Try[T]) = promise.tryComplete(result)
+  }
+
+  private def _toLoadingNode(content: Future[Node]) =
+    Signal.fromFuture(content) map {
+      case Some(node) => node
+      case None       => div(cls := "loader",
+        span(),
+        span(),
+        span(),
+        span(),
+        span(),
+        span()
+      )
+    }
+  
+  private val _backgroundStyle = Seq(
+    backgroundColor := "rgba(0, 0, 0, 0.5)",
+    position        := "fixed",
+    textAlign       := "center",
+    top             := "0",
+    left            := "0",
+    bottom          := "0",
+    right           := "0",
+    zIndex          := 99
+  )
+
+  private val _popupWindowStyle = Seq(
+    cls             := "p-4 position-relative",
+    backgroundColor := "#ffffff",
+    borderRadius    := "1em",
+    top             := "50%",
+    transform       := "translateY(-50%)",
+    maxHeight       := "80%",
+    overflowY       := "auto",
+    display         := "inline-block",
+    verticalAlign   := "middle",
+    maxWidth        := "50%"
+  )
+
+  private def _internal[T](content: Node)(implicit ctx: ExecutionContext) = {
+    val promise = Promise[T]()
+    val overlay = div(
+      _backgroundStyle,
+      div(
+        _popupWindowStyle,
+        content
+      )
+    )
+    overlay.amend(onClick.stopPropagation --> ((event) =>
+      if (event.target == overlay.ref) {
+        promise.failure(Closed)
+      }
+    ))
+    new _PromiseWrapper(promise, render(document.body, overlay))
+  }
+
+  def confirm[T](
+    content:       Node,
+    readyToSubmit: Signal[Boolean] = Val(true)
+  )(confirmFn: () => T) = {
+    val cancelBtn = button(
+      typ := "button",
+      cls := "btn btn-light",
+      "Cancel"
+    )
+    val submitBtn = button(
+      typ := "button",
+      cls := "btn btn-success",
+      disabled <-- readyToSubmit.map(!_),
+      "Submit"
+    )
+    val promise = _internal[T](div(
+      content,
+      div(
+        cls       := "pt-2 pb-2",
+        textAlign := "right",
+        cancelBtn,
+        submitBtn
+      )
+    ))
+    cancelBtn.amend(
+      onClick.stopPropagation --> ((_) => promise.failure(Closed))
+    )
+    submitBtn.amend(
+      onClick.stopPropagation --> ((_) => promise.success(confirmFn()))
+    )
+    promise.future
+  }
+
+  def confirmFuture[T](
+    content:       Future[Node],
+    readyToSubmit: Signal[Boolean] = Val(true)
+  )(confirmFn: () => Future[T])(implicit ctx: ExecutionContext) = {
+    val cancelBtn = button(
+      typ := "button",
+      cls := "btn btn-light",
+      "Cancel"
+    )
+    val submitBtn = button(
+      typ := "button",
+      cls := "btn btn-success",
+      disabled <-- readyToSubmit.map(!_),
+      "Submit"
+    )
+    val promise = _internal[T](div(
+      child <-- _toLoadingNode(content),
+      div(
+        cls       := "pt-2 pb-2",
+        textAlign := "right",
+        cancelBtn,
+        submitBtn
+      )
+    ))
+    cancelBtn.amend(
+      onClick.stopPropagation --> ((_) => {promise.failure(Closed)})
+    )
+    submitBtn.amend(
+      onClick.stopPropagation --> ((_) => {promise.completeWith(confirmFn())})
+    )
+    promise.future
+  }
+}

+ 70 - 0
webClient/src/main/scala/com/weEat/modules/PageSelect.scala

@@ -0,0 +1,70 @@
+package com.weEat.modules
+
+import com.raquo.laminar.api.L._
+import io.laminext.syntax.core._
+
+case class PageSelect(
+  numPages: Signal[Int],
+  revealedRadius: Signal[Int] = Val(3)
+) extends Module {
+  private val _page = Var[Int](1)
+
+  // TODO: Add buttons for First/Last page
+
+  // TODO: Allow the user to click on `...` to temporarily expand the upper or
+  // lower reveal boundary
+  private val _minRevealed = _page.signal.combineWithFn(revealedRadius) {
+    case (p, r) => 1.max(p - r)
+  }
+  private val _maxRevealed = _page.signal.combineWithFn(revealedRadius, numPages) {
+    case (p, r, n) => n.min(p + r)
+  }
+
+  val page = _page.signal.combineWithFn(numPages) {
+    case (idx, pages) => idx.min(pages) - 1
+  }
+
+  private def idxToString(targIdx: Int, str: String) =
+    _page.signal.map({page: Int => if (page == targIdx) str else ""})
+
+  val render = ul(cls := "pagination",
+    li(cls <-- idxToString(1, " disabled").map("page-item"+_),
+      a(cls := "page-link",
+        href := "#",
+        onClick --> { (_) => _page.update(_ - 1)},
+        span("<"),
+        span(cls := "sr-only", "Previous")
+      )
+    ),
+    li(cls := "page-item disabled",
+      display <-- _minRevealed.map(_ <= 1).switch("none", ""),
+      a(cls := "page-link", href := "#", "...")
+    ),
+    children <-- _minRevealed.combineWithFn(_maxRevealed) {
+      case (min, max) => for (i <- (min to max)) yield li(
+        cls <-- idxToString(i, " active").map("page-item"+_),
+        a(cls := "page-link",
+          href := "#",
+          onClick --> { (_) => _page.update(_ => i) },
+          i.toString
+        )
+      )
+    },
+    li(cls := "page-item disabled",
+      display <-- _maxRevealed.combineWithFn(numPages)({
+        case (max, n) => n <= max
+      }).switch("none", ""),
+      a(cls := "page-link", href := "#", "...")
+    ),
+    li(cls <-- numPages.flatMap({ (targIdx) =>
+      _page.signal.map({ (page: Int) => if (page >= targIdx) " disabled" else "" })
+    }).map("page-item"+_),
+      a(cls := "page-link",
+        href := "#",
+        onClick --> { (_) => _page.update(_ + 1)},
+        span(">"),
+        span(cls := "sr-only", "Next")
+      )
+    )
+  )
+}

+ 32 - 0
webClient/src/main/scala/com/weEat/modules/PaginatedTable.scala

@@ -0,0 +1,32 @@
+package com.weEat.modules
+
+import com.raquo.laminar.api.L._
+
+case class PaginatedTable[T](
+  structure: Seq[(String, Short, (T) => Node)],
+  tblData: Signal[Seq[T]],
+  page: Signal[Int] = Val(0),
+  pageSize: Signal[Int] = Val(10)
+) extends Module {
+  val render = table(cls := "table table-striped table-hover",
+    thead(
+      tr(
+        for (col <- structure) yield Seq(
+          th(cls := s"col-md-${col._2}",
+            span(col._1)
+          )
+        )
+      )
+    ),
+    tbody(
+      children <-- tblData.combineWithFn(page, pageSize) {
+        case (data, idx, size) =>
+          data.slice(idx*size, (idx+1)*size).map({ (datum) =>
+            tr(
+              structure.map({ (col) => td(col._3(datum)) })
+            )
+          })
+      }
+    )
+  )
+}

+ 0 - 0
client/src/main/scala/com/weEat/modules/README.md → webClient/src/main/scala/com/weEat/modules/README.md


+ 42 - 0
webClient/src/main/scala/com/weEat/modules/SearchBar.scala

@@ -0,0 +1,42 @@
+package com.weEat.modules
+
+import com.raquo.laminar.api.L._
+import io.laminext.syntax.core._
+
+import com.weEat.modules._
+import scala.concurrent.duration._
+import scala.concurrent.{ExecutionContext,Future}
+
+case class SearchBar[T](
+  searchFn: (String) => Future[T],
+  minLength: Int = 3,
+  delay: FiniteDuration = 300 milliseconds,
+  inSignal: Signal[String] = Val("")
+)(implicit val ec: ExecutionContext) extends Module {
+
+  private val _input = input(typ := "text",
+    cls := "form-control input-sm",
+    value <-- inSignal.map({(x) => println(x);x})
+  )
+
+  private val _inputTerm = _input.value.composeChanges((stream) =>
+    EventStream.merge(stream, inSignal.changes)
+  )
+
+  val render = _input
+
+  val searchTerm: Signal[Option[String]] = _inputTerm
+    .map({ (str) => Option.when(str.length >= minLength)(str) })
+    .composeChanges({ (termStream) =>
+      termStream
+        .debounce(delay.toMillis.toInt)
+    }).distinct
+
+  val result: Signal[Option[T]] = searchTerm
+    .optionMap(searchFn)
+    .optionMap(Signal.fromFuture(_)(ec))
+    .shiftOption
+    .composeChanges({ (resultOptOptStream) =>
+      resultOptOptStream.filter(_.map(_.isDefined).getOrElse(true))
+    }).map(_.flatten)
+}

+ 29 - 0
webClient/src/main/scala/com/weEat/modules/Select.scala

@@ -0,0 +1,29 @@
+package com.weEat.modules
+
+import com.raquo.laminar.api.L._
+
+case class Select[T](values: Signal[Seq[T]], defaul: Option[T] = None) extends Module {
+
+
+  private val _selectedIdx = Var[Option[Int]](None)
+
+  private val _select = select(cls := "form-control input-sm",
+    children <-- values.splitByIndex {
+      case (idx, _, valStream) => option(
+        selected <-- valStream.map(Some(_) == defaul),
+        com.raquo.laminar.api.L.value := idx.toString,
+        child.text <-- valStream.map(_.toString)
+      )
+    },
+    onChange.mapToValue.map(_.toInt).map(Some(_)) --> _selectedIdx
+  )
+
+  val value = _selectedIdx.signal.combineWithFn(values) {
+    case (Some(idx), v) => v(idx)
+    case (None, v) => defaul.getOrElse(v(0))
+  }
+
+  val render = div(cls := "form-group form-group-sm",
+    _select
+  )
+}

+ 41 - 0
webClient/src/main/scala/com/weEat/modules/TypeaheadRx.scala

@@ -0,0 +1,41 @@
+package com.weEat.modules
+
+import com.raquo.laminar.api.L._
+import com.weEat.modules._
+import com.tflucke.typeahead._
+import scala.concurrent.ExecutionContext
+
+case class TypeaheadRx[T](val datasets: Dataset[T]*)
+(implicit val ec: ExecutionContext) extends Module {
+
+  import TypeaheadRx._
+
+  private val _query = Var("")
+  private val _result = Var[Option[T]](None)
+
+  val render = input(typ := "text",
+    cls := "form-control input-sm",
+    onMountCallback({ (ctx) =>
+      val elm = ctx.thisNode
+      // If we try to mount it immediately, the parent won't exist yet.
+      // Wait for the mounting to finish, then wrap in typeahead
+      TypeaheadElement(
+        elm.ref
+      )(datasets)
+      elm.amend(
+        onSelected[T] --> { (e: CursorEvent[T]) =>
+          _result.set(e.selectable.map(_.data))
+        },
+        onQueryChanged --> { (e: QueryEvent) => _query.set(e.query) }
+      )
+    })
+  )
+
+  val query = _query.signal
+  val value = _result.signal
+}
+
+object TypeaheadRx {
+  def onSelected[T] = new EventProp[CursorEvent[T]]("typeahead:selected")
+  val onQueryChanged = new EventProp[QueryEvent]("typeahead:queryChanged")
+}

+ 325 - 0
webClient/src/main/scala/com/weEat/modules/USDAEditor.scala

@@ -0,0 +1,325 @@
+package com.weEat.modules
+
+import com.raquo.laminar.api.L._
+import io.laminext.syntax.core._
+
+import com.weEat.controllers.USDAController
+import com.weEat.shared.models.UnitType
+import com.weEat.shared.models.UnitType._
+import scala.util.{Success,Failure}
+import com.weEat.shared.models.{USDANodeNoId,USDANodeId}
+import gov.usda.nal.fdc.models.{FullFoodItem,FoodPortion}
+import com.weEat.models.Nutrient
+import com.weEat.shared.models.{Count,MeasureUnit,USDANode,ParsedMeasure}
+
+case class USDAEditor(
+  usda: USDANode,
+  defaultOverNone: Boolean
+) extends Module {
+  implicit val ctx = com.weEat.shared.ctx
+
+  private case class Measure(
+    portion: FoodPortion,
+    override val unit: MeasureUnit,
+    override val raw: String
+  ) extends ParsedMeasure(unit, raw)
+
+  private val _measures =
+    Signal.fromFuture(USDAController.getFood(usda.fdcId)().transform({
+      case Success(full: FullFoodItem) => Success(
+        full.foodPortions.map({ (port) =>
+          val str = portionStr(port)
+          Measure(port, portionToMeasure(port, str), str)
+        })
+      )
+      case Success(_) => Success(Nil)
+      case Failure(e) => {
+        Console.err.println(e)
+        Failure(e)
+      }
+    }), Nil)
+
+  private val _fdcId = usda.fdcId
+
+  private val _nameIn = input(typ := "text",
+    cls := "form-control",
+    textAlign := "center",
+    value := usda.name
+  )
+  private val _name = _nameIn.value
+
+  private val _calorieIn = floatInput("calories", Some(usda.calories))
+  private val _calorie = _calorieIn.value
+
+  val noneValue = "none"
+  val customValue = "custom"
+
+  val defaultVolPort = _measures.map(getDefaultPortion(_, VOLUME, usda.density))
+
+  private val _volume = Var[String](noneValue)
+  private val _volumeIn = select(cls := "custom-select",
+    option(selected <-- defaultVolPort.map(_ == None && usda.density == None),
+      value := noneValue,
+      "None"
+    ),
+    option(selected <-- defaultVolPort.map(_ == None && usda.density != None),
+      value := customValue,
+      "Custom"
+    ),
+    children <-- _measures.map(
+      _.filter(_.unit.typ == VOLUME)
+        .map({ case Measure(p, m, rawName) =>
+          option(selected <-- defaultVolPort.optionContains(p),
+            value := calcConversion(p, m).toString,
+            rawName,
+            onMountCallback({ (ctx) =>
+              import org.scalajs.dom.Event
+              ctx.thisNode.ref.parentElement.dispatchEvent(new Event("change"))
+            })
+          )
+        })
+    ),
+    onChange.mapToValue --> _volume
+  )
+
+  val defaultNumPort = _measures.map(getDefaultPortion(_, NUMBER, usda.massPerUnit))
+
+  private val _count = Var[String](noneValue)
+  private val _countIn = select(cls := "custom-select",
+    option(selected <-- defaultNumPort.map(_ == None && usda.massPerUnit == None),
+      value := noneValue,
+      "None"
+    ),
+    option(selected <-- defaultNumPort.map(_ == None && usda.massPerUnit != None),
+      value := customValue,
+      "Custom"
+    ),
+    children <-- _measures.map(
+      _.filter(_.unit.typ == NUMBER)
+        .map({ case Measure(p, m, rawName) =>
+          option(selected <-- defaultNumPort.optionContains(p),
+            value := calcConversion(p, m).toString,
+            rawName,
+            onMountCallback({ (ctx) =>
+              import org.scalajs.dom.Event
+              ctx.thisNode.ref.parentElement.dispatchEvent(new Event("change"))
+            })
+          )
+        })
+    ),
+    onChange.mapToValue --> _count
+  )
+
+  private val _densityIn = input(cls := "form-control",
+    typ := "number",
+    stepAttr := "any",
+    minAttr := "0",
+    disabled <-- !_volume.signal.valueIs(customValue),
+    value <-- _volume.signal.composeChanges(
+      _.map(_.toFloatOption).filter(_.nonEmpty).map(_.get.toString)
+    )
+  )
+  private val _density = _densityIn.value
+
+  private val _massIn = input(cls := "form-control",
+    typ := "number",
+    stepAttr := "any",
+    minAttr := "0",
+    disabled <-- !_count.signal.valueIs(customValue),
+    value <-- _count.signal.composeChanges(
+      _.map(_.toFloatOption).filter(_.nonEmpty).map(_.get.toString)
+    )
+  )
+  private val _mass = _massIn.value
+
+  private val _typ = Var[UnitType](UnitType.MASS)
+  private val _unitTypeIn = select(idAttr := "unit",
+    cls := "form-control",
+    disabled := true,
+    for (unitType <- UnitType.values.toSeq)
+    yield option(value := unitType.toString, unitType.toString),
+    onChange.mapToValue.map(UnitType.withName) --> _typ
+  )
+
+  private val _nutrientInputs = usda.nutrients.map({case (k, v) =>
+    (k, floatInput(k, Some(v)))
+  })
+
+  def floatInput(id: String, v: Option[Float]) = input(idAttr := id,
+    cls := "form-control",
+    typ := "number",
+    stepAttr := "any",
+    minAttr := "0",
+    value := v.getOrElse(0.0).toString
+  )
+
+  def getUSDANode() = {
+    import com.raquo.airstream.ownership.ManualOwner
+    implicit val owner = new ManualOwner()
+
+    (usda match {
+      case node: USDANodeId =>
+        Function.uncurried((USDANodeId.apply _).curried(node._id))
+          .asInstanceOf[(
+            String,
+            Long,
+            Option[Float],
+            Option[Float],
+            Float,
+            Map[String, Float]
+          ) => USDANodeId]
+      case _ =>
+        USDANodeNoId.apply _
+    })(
+      _name.observe.now(),
+      _fdcId,
+      _volume.signal.flatMap({
+        case `noneValue` => Val(None)
+        case `customValue` => _density.map(str => Some(str.toFloat))
+        case value => Val(Some(value.toFloat))
+      }).observe.now(),
+      _count.signal.flatMap({
+        case `noneValue` => Val(None)
+        case `customValue` => _mass.map(str => Some(str.toFloat))
+        case value => Val(Some(value.toFloat))
+      }).observe.now(),
+      //_typ,
+      _calorie.observe.now().toFloat,
+      _nutrientInputs.map({case (k, in) => (k, in.value.observe.now().toFloat)})
+    )
+  }
+
+  val undefinedMeasureUnitId = 9999
+
+  def portionStr(fp: FoodPortion) =
+    fp.portionDescription.getOrElse(fp.modifier.getOrElse("Unknown"))
+
+  def portionToMeasure(fp: FoodPortion, str: String): MeasureUnit =
+    if (fp.measureUnit.id != undefinedMeasureUnitId) Count
+    else MeasureUnit.guessUnit(str).getOrElse(Count)
+
+  val measureMatchThreshold = 0.01
+
+  /* Procedure to determine default:
+   * 1. Check if there is some value already:
+   * | yes:
+   * |- 2. Find the measure which most closely results in that value
+   * |  3. If closest match is not within the threshold of the value, use custom
+   * | no:
+   * |- 2. if defaultOverNone:
+   * |  | yes:
+   * |  |- 3. Use (portion, measure) which has the best matching between the two
+   * |  | no:
+   * |  |- 3. Use none
+   */
+  private def getDefaultPortion(
+    measures: Seq[Measure],
+    typ: UnitType,
+    value: Option[Float]
+  ): Option[FoodPortion] = (value match {
+    case Some(dV) => measures.filter(_.unit.typ == typ).minByOption({
+      case Measure(portion, measure, _) => {
+        val diff = (calcConversion(portion, measure) - dV).abs
+        Option.when(diff < measureMatchThreshold)(diff)
+      }
+    })
+    case None => Option.when(defaultOverNone)(
+      MeasureUnit.closestPair(measures.filter(_.unit.typ == typ))
+    ).flatten
+  }).map(_.portion)
+
+  def calcConversion(fp: FoodPortion, meas: MeasureUnit) =
+    fp.gramWeight/meas.conversionRatio
+
+  private val _detailsCollapsed = Var(true)
+  val render = div(cls := "form-group",
+    div(cls := "container",
+      div(cls := "row",
+        div(cls := "col-12", _nameIn)
+      ),
+      div(cls := "row",
+        div(cls := "col-6",
+          label(forId := "fdcid", "USDA Id: "),
+          input(idAttr := "fdcid", 
+            cls := "form-control",
+            typ := "number",
+            value := _fdcId.toString,
+            disabled := true
+          )
+        ),
+        div(cls := "col-6",
+          label(forId := "calories", "Calories/100g: "),
+          _calorieIn
+        )
+      ),
+      div(cls := "row",
+        div(cls := "col-6",
+          label(forId := "density", "Volume Unit: "),
+          _volumeIn
+        ),
+        div(cls := "col-6",
+          label(forId := "weight", "Count Unit: "),
+          _countIn
+        )
+      ),
+      div(cls := "row",
+        div(cls := "col-6",
+          label(forId := "density", "Density (g/ml): "),
+          _densityIn
+        ),
+        div(cls := "col-6",
+          label(forId := "weight", "Mass (g/unit): "),
+          _massIn
+        )
+      ),
+      div(cls := "row",
+        div(cls := "col-6",
+          label(forId := "unit", "Unit: "),
+          _unitTypeIn
+        )
+      )
+    ),
+    div(cls := "panel-group", margin := "1em",
+      div(cls := "panel, panel-default",
+        div(cls := "panel-heading",
+          div(cls := "panel-title",
+            a(href := "#detailsDiv",
+              cls := "btn",
+              dataAttr("toggle") := "collapse",
+              dataAttr("aria-controls") := "detailsDiv",
+              role := "button",
+              onClick --> {(_) => _detailsCollapsed.update(!_)},
+              "Details"
+            )
+          )
+        )
+      ),
+      div(idAttr := "detailsDiv",
+        cls := "panel-collapse",
+        display <-- _detailsCollapsed.signal.switch("none", ""),
+        div(cls := "panel-body",
+          div(cls := "container",
+            div(cls := "row", (
+              for ((id, in) <- _nutrientInputs)
+              yield div(cls := "col-md-6",
+                label(forId := id,
+                  child.text <-- Nutrient.nutrients.map({
+                    case Some(nutrs) => nutrs
+                        .find(x => x.number == id)
+                        .map(x => s"${x.name} (${x.unitName})")
+                        .getOrElse({
+                          id.toString
+                        })
+                    case None => id.toString
+                  }),
+                  ": "
+                ),
+                in
+              )
+            ).toSeq)
+          )
+        )
+      )
+    )
+  )
+}

+ 0 - 0
client/src/main/scala/com/weEat/util/Cookie.scala → webClient/src/main/scala/com/weEat/util/Cookie.scala


+ 1 - 1
client/src/main/scala/com/weEat/util/Storage.scala → webClient/src/main/scala/com/weEat/util/Storage.scala

@@ -24,7 +24,7 @@ trait Storage {
     this
   }
   def clear(): Storage = {
-    native.clear
+    native.clear()
     this
   }
 }

+ 76 - 0
webClient/src/main/scala/com/weEat/views/FoodSearch.scala

@@ -0,0 +1,76 @@
+package com.weEat.view
+
+import com.raquo.laminar.api.L._
+import io.laminext.syntax.core._
+import com.raquo.waypoint._
+
+import play.api.libs.json.{JsValue,Json}
+import com.weEat.modules._
+import com.weEat.controllers.FoodController
+import com.weEat.shared.models._
+
+object FoodSearch extends View[Option[String]] {
+  implicit val ctx = com.weEat.shared.ctx
+
+  val navName = "Food Search"
+  val tag = "foodsearch"
+
+  private val SEARCH_PAGE_SIZE = 40.asInstanceOf[Short]
+
+  case class ViewPage(val q: Option[String] = None) extends P {
+    val title = "Food Search" + q.map((q) => s" - $q").getOrElse("")
+    def jsonValue = Json.toJson(q)
+  }
+  def parseJson(jsVal: JsValue) = ViewPage(jsVal.asOpt[String])
+  def route = Route.onlyQuery(
+    encode = (page: ViewPage) => page.q,
+    decode = (q: Option[String]) => ViewPage(q = q),
+    pattern = (root / tag / endOfSegments) ? param[String]("q").?
+  )
+  def defaultPage = ViewPage()
+
+  def content(queryPage: Signal[ViewPage]) = {
+    val searchBar: SearchBar[Seq[FoodNodeId]] = SearchBar(
+      (term) => FoodController.query(term)(),
+      inSignal = queryPage.map(_.q).withDefault("")
+    )
+    val searchResults = searchBar.result.withDefault(Nil)
+    val pageSizeSel = Select(Val((10 to SEARCH_PAGE_SIZE by 10)))
+    val numPages = pageSizeSel.value.combineWithFn(searchResults) {
+      case (size, results) => (results.length + size - 1) / size
+    }
+    val pageSel = PageSelect(numPages)
+
+    div(
+      h2("Food Search"),
+      div(cls := "form-group",
+        label(forId := "search", "Search: "),
+        searchBar.render
+      ),
+      searchBar.searchTerm --> { (str: Option[String]) =>
+        val oldQ = View.router.currentPageSignal.now().asInstanceOf[ViewPage].q
+        if (oldQ != str)
+          View.router.pushState(ViewPage(q = str))
+      },
+      PaginatedTable[FoodNodeId](Seq(
+        ("", 1, { (x) => button(cls := "btn btn-light",
+          "View"
+        )}),
+        ("Name", 3, { (x) => span(x.name)}),
+        ("Type", 4, { (x) => span(x.nodeType.toString)}),
+        ("User", 2, { (x) => span(futureChild <-- x.user.map { _.email })}),
+        ("Source", 2, { (x) => span()})
+      ),
+        searchResults,
+        pageSel.page,
+        pageSizeSel.value
+      ).render,
+      (pageSel.render).amendThis(
+        (elm) => cls := s"${elm.ref.className} col-3 float-left"
+      ),
+      (pageSizeSel.render).amendThis(
+        (elm) => cls := s"${elm.ref.className} col-1 float-right"
+      )
+    )
+  }
+}

+ 0 - 0
client/src/main/scala/com/weEat/views/README.md → webClient/src/main/scala/com/weEat/views/README.md


+ 383 - 0
webClient/src/main/scala/com/weEat/views/RecipeEdit.scala

@@ -0,0 +1,383 @@
+package com.weEat.view
+
+import com.raquo.laminar.api.L._
+import io.laminext.syntax.core._
+import com.raquo.airstream.ownership.ManualOwner
+import com.raquo.waypoint._
+
+import play.api.libs.json.{JsValue,Json}
+import com.weEat.controllers.{FoodController,USDAController}
+import com.weEat.modules._
+import com.weEat.shared.models.UnitType._
+import com.weEat.shared.models._
+import org.scalajs.dom.{Event,KeyboardEvent,HTMLInputElement,HTMLElement}
+import com.tflucke.sortable.{Sortable, SortableOptions, SortableEvent}
+import com.tflucke.typeahead._
+import com.tflucke.typeahead.Dataset.Templates
+import org.scalajs.dom.document
+import gov.usda.nal.fdc.models.DataType._
+import scala.concurrent.Future
+import scala.util.{Success,Failure}
+
+// TODO: prevent user from not having any of discreet/mass/volume input 
+// TODO: Save recipe node in cookie until ready to use
+object RecipeEdit extends View[Option[String]] {
+  import com.weEat.Main.headers
+
+  implicit val ec = com.weEat.shared.ctx
+
+  val navName = "New Recipe"
+  val tag = "editRecipe"
+
+  case class ViewPage(val id: Option[String] = None) extends P {
+    val title = "Edit Recipe"
+    def jsonValue = Json.toJson(id)
+  }
+  def parseJson(jsVal: JsValue) = ViewPage(jsVal.asOpt[String])
+  def route = Route.onlyQuery(
+    encode = (page: ViewPage) => page.id,
+    decode = (id: Option[String]) => ViewPage(id = id),
+    pattern = (root / tag / endOfSegments) ? param[String]("id").?
+  )
+  def defaultPage = ViewPage()
+
+  val ENTER_KEY_CODE = 13
+
+  override def permissions = Set("user")
+
+  private val _nameIn = input(idAttr := "name",
+    cls := "form-control",
+    required := true
+  )
+  private val _name: Signal[String] = _nameIn.value
+
+  private val internalFoodDS = Dataset(
+    { _ => Nil},
+    Some({ (str: String) => FoodController.query(str.toLowerCase)() }),
+    display = { (food: FoodNodeId) => food.name }
+  )
+
+  private val usdaFoodDS = Dataset(
+    { _ => Nil},
+    Some({ str: String =>
+      USDAController.getFoodsSearch(str, Seq(
+        Foundation, Survey, SRLegacy
+      ).map(_.toString))().map(_.foods.map(USDANodeNoId.fromSearchResult))
+    }),
+    templates = Some(Templates({(x: USDANodeNoId) => x.name}).copy(
+      header = Some({ (_: String, _: Seq[USDANodeNoId], _: String) =>
+        val html = document.createElement("div").asInstanceOf[HTMLElement]
+        html.innerText = "Unoffical Foods"
+        html
+      })
+    )),
+    display = {(x: USDANodeNoId) => x.name}
+  )
+
+  private val _ingredientSearch = input(typ := "text",
+    cls := "form-control input-sm",
+    onMountCallback({(ctx) =>
+      val elm = ctx.thisNode
+      TypeaheadElement[FoodNode](
+        elm.ref.asInstanceOf[HTMLInputElement],
+        minLength = 3
+      )(Seq(internalFoodDS, usdaFoodDS))
+      elm.amend(
+        TypeaheadRx.onSelected[FoodNode] --> { (e: CursorEvent[FoodNode]) =>
+          e.selectable.map(_.data).foreach({ node =>
+            // TODO: default unit
+            _editIngredient(Ingredient.fromFoodNode(node, 0, Gram)) { in =>
+              _ingredients.update(_ :+ in)
+              elm.ref.value = ""
+              Future.successful(())
+            }
+          })
+        }
+      )
+    })
+  )
+
+  private val _stepIn = input(idAttr := "step",
+    cls := "form-control",
+    onKeyPress --> { (e: KeyboardEvent) => if (e.keyCode == ENTER_KEY_CODE) {
+      val elm = e.target.asInstanceOf[HTMLInputElement]
+      _steps.update(_ :+ elm.value)
+      elm.value = ""
+    } }
+  )
+
+  private val _ingredients = Var[Seq[Ingredient]](Nil)
+  private val _steps = Var[Seq[String]](Nil)
+
+  private val _servingsIn = input(idAttr := "serv",
+    typ := "number",
+    cls := "form-control",
+    defaultValue := "4",
+    stepAttr := "0.1",
+    required := true
+  )
+  private val _servings = _servingsIn.value
+
+  private val _discreetIn = input(idAttr := "discreet",
+    typ := "checkbox",
+    cls := "form-check-input",
+    defaultChecked := true
+  )
+  private val _discreet = _discreetIn.checked
+
+  // TODO: Try autopopulate ingredients.sum(_.massPerUnit)
+  private val _massPIn = input(idAttr := "massP",
+    cls := "form-control",
+    typ := "number",
+    minAttr := "1",
+    stepAttr := "0.1",
+    value := "100"
+  )
+  private val _massP = _massPIn.value
+
+  // TODO: Try autopopulate ingredients.averge(_.density)
+  private val _volPIn = input(idAttr := "volP",
+    cls := "form-control",
+    typ := "number",
+    minAttr := "0",
+    stepAttr := "0.1" 
+  )
+  private val _volP = _volPIn.value
+
+  private val _volPUnit = Var[MeasureUnit](Milliliter)
+  private val _volPUnitIn = select(cls := "custom-select",
+    MeasureUnit.units.zipWithIndex
+      .filter({ case (u, _) => u.typ == VOLUME })
+      .map({ case (unit, idx) =>
+        // TODO: default selected dynamic
+        option(
+          value := idx.toString,
+          selected := unit.abr == Milliliter.abr,
+          unit.abr
+        )
+      }),
+    onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> _volPUnit
+  )
+
+  val recipieNode = _ingredients.signal.combineWithFn(
+    _steps,
+    _name,
+    _discreet,
+    _servings,
+    _volP,
+    _volPUnit,
+    _massP
+  ) { case (ingredients, steps, name, discreet, servings, volP, volPUnit,
+    massP) =>
+      val gPServ = massP.toFloatOption
+      val mLPServ = volP.toFloatOption
+        .map( _ * volPUnit.conversionRatio )
+      val (servFactor, servUnit) = gPServ.zip(Some(Gram))
+        .getOrElse(
+          if (discreet) (1.0f, Count)
+          else mLPServ.zip(Some(Milliliter)).getOrElse(???)
+        ).asInstanceOf[(Float, MeasureUnit)]
+      val numServings = servings.toFloatOption.getOrElse(1.0f)
+      val stdQtiesPServing = servFactor / servUnit.typ.standardQuanity
+      val stdQties = numServings * stdQtiesPServing
+
+      RecipeNodeNoId(
+        name,
+        stdQties,
+        stdQtiesPServing,
+        servUnit.typ,
+        ingredients,
+        steps,
+        gPServ.zip(mLPServ).map({ case (m, v) => (m/v).toFloat }),
+        gPServ
+      )
+    }
+
+  private def _editIngredient(ing: Ingredient)(callback: (Ingredient => Future[_])) = {
+    val amount = Var[Float](ing.amount)
+    val unit = Var[MeasureUnit](Gram)
+
+    Overlay.confirmFuture(ing.food.map({ (food) =>
+      val amountIn = input(typ := "number",
+        cls := "form-control input-sm col-9",
+        minAttr := "0",
+        defaultValue := ing.amount.toString,
+        onInput.mapToValue.map(_.toFloat) --> amount
+      )
+      val unitIn = select(cls := "col-3 custom-select",
+        MeasureUnit.units.zipWithIndex.map({ case (unit, idx) =>
+          // TODO: default selected dynamic
+          option(value := idx.toString,
+            selected := unit.abr == Gram.abr,
+            unit.name
+          )
+        }),
+        onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> unit
+      )
+
+      div(cls := "row", amountIn, unitIn)
+    }),
+      amount.signal.recoverToTry.map(_.isSuccess) &&
+        unit.signal.recoverToTry.map(_.isSuccess)
+    ) { () =>
+      callback(ing.copy(
+        amount = amount.now(),
+        unit = unit.now()
+      ))
+    }
+  }
+
+  private def _removeFromList[T](s: Seq[T], idx: Int) =
+    s.take(idx) ++ s.drop(idx + 1)
+
+  private val _stepList = ol(
+    listStyleType := "none",
+    paddingLeft := "0",
+    children <-- _steps.signal.map(_.zipWithIndex.map((presentStep _).tupled)),
+    onMountCallback({(ctx) =>
+      Sortable.create(ctx.thisNode.ref, SortableOptions.onUpdate({
+      (event: SortableEvent) => 
+        _steps.update({ steps =>
+          (event.oldIndex.toOption, event.newIndex.toOption) match {
+            case (Some(old), Some(ne)) if (old < ne) =>
+              moveBackwards(steps, old, ne)
+            case (Some(old), Some(ne)) =>
+              moveBackwards(
+                steps.reverse,
+                steps.size - old - 1,
+                steps.size - ne - 1
+              ).reverse
+            case (None, Some(ne)) =>
+              val (first, second) = steps.splitAt(ne)
+              (first :+ event.item.innerText) ++ second
+            case (Some(old), None) =>
+              _removeFromList(steps, old)
+          }
+        })
+        // If the added element is still in the parent, remove it since the
+        // Rx.update will have generated a new one.
+        Option(event.item.parentElement).map(_.removeChild(event.item))
+
+        def moveBackwards(steps: Seq[String], old: Int, ne: Int) =
+          (steps.take(old) ++
+            steps.take(ne+1).drop(old + 1) :+
+            steps(old)) ++
+            steps.drop(ne+1)
+      }))
+    })
+  )
+
+  def presentFoodNode(idx: Int)(ingredientSig: Signal[Ingredient]) =
+    li(children <-- ingredientSig.flatMap { (ingredient: Ingredient) =>
+      Signal.fromFuture(ingredient.food).optionMap { (food) =>
+        Seq(
+          span(cls := "ui-icon ui-icon-pencil",
+            onClick --> { (e: Event) =>
+              _editIngredient(ingredient) { (in) =>
+                _ingredients.update(_.updated(idx, in))
+                Future.successful(())
+              }
+            }
+          ),
+          span(cls := "ui-icon ui-icon-close",
+            onClick --> { (e: Event) =>
+              _ingredients.update(_.filterNot(_ == ingredient))
+            }
+          ),
+          span(f"${ingredient.amount}%.02f${ingredient.unit.abr} ${food.name}")
+        )
+      } withDefault(Nil)
+    })
+
+  def presentStep(step: String, idx: Int) = li(
+    span(cls := "ui-icon ui-icon-close",
+      onClick --> { (e: Event) =>
+        _steps.update(_removeFromList(_, idx))
+      }
+    ),
+    step
+  )
+
+  def content(page: Signal[ViewPage]) = div(
+    h2("Recipe Editor"),
+    div(cls := "form-group",
+      div(cls := "container",
+        div(cls := "row",
+          div(cls := "col-md-12",
+            label("Name: "),
+            _nameIn
+          )
+        ),
+        div(cls := "row",
+          div(cls := "col-md-3 form-check",
+            div(cls := "form-check",
+              _discreetIn,
+              label(cls := "form-check-label", 
+                forId := "discreet",
+                "Descrete Servings"
+              )
+            )
+          ),
+          div(cls := "col-md-3 input-group",
+            _servingsIn,
+            div(cls := "input-group-append",
+              label(cls := "input-group-text", "Servings")
+            )
+          ),
+          div(cls := "col-md-3 input-group",
+            _massPIn,
+            div(cls := "input-group-append",
+              label(cls := "input-group-text", "g/Serving")
+            )
+          ),
+          div(cls := "col-md-3 input-group",
+            _volPIn,
+            _volPUnitIn,
+            div(cls := "input-group-append",
+              label(cls := "input-group-text", "/Serving"),
+            ),
+          )
+        ),
+        div(cls := "row",
+          div(cls := "col-md-5",
+            h2("Ingredients"),
+            _ingredientSearch,
+            ul(
+              listStyleType := "none",
+              paddingLeft := "0",
+              children <-- _ingredients.signal.splitByIndex {
+                case (idx, _, ingredientStream) =>
+                  presentFoodNode(idx)(ingredientStream)
+              }
+            )
+          ),
+          div(cls := "col-md-5",
+            h2("Steps"),
+            _stepIn,
+            _stepList
+          ),
+          div(cls := "col-md-2",
+            NutritionPane(recipieNode).render
+          )
+        )
+      ),
+      button(cls := "btn",
+        onClick --> { (e: Event) =>
+          implicit val owner = new ManualOwner()
+
+          // import play.api.libs.json.Json
+          // println(recipieNode.value)
+          // println(Json.toJson(recipieNode.value))
+          // println(Json.stringify(Json.toJson(recipieNode.value)))
+          FoodController.add()(recipieNode.observe.now()).onComplete {
+            case Success(_) => println("Success!")
+            case Failure(ex) =>
+              println("Could not add recipe")
+              throw ex
+          }
+        },
+        "Add"
+      )
+    )
+  )
+}

+ 370 - 0
webClient/src/main/scala/com/weEat/views/RecipeView.scala

@@ -0,0 +1,370 @@
+// package com.weEat.view
+
+// import com.raquo.laminar.api.L._
+// import io.laminext.syntax.core._
+// import com.raquo.airstream.ownership.ManualOwner
+
+// import com.weEat.controllers.{FoodController,USDAController}
+// import com.weEat.modules._
+// import com.weEat.shared.models.UnitType._
+// import com.weEat.shared.models._
+// import org.scalajs.dom.{Event,KeyboardEvent,HTMLInputElement,HTMLElement}
+// import com.tflucke.sortable.{Sortable, SortableOptions, SortableEvent}
+// import com.tflucke.typeahead._
+// import com.tflucke.typeahead.Dataset.Templates
+// import org.scalajs.dom.document
+// import gov.usda.nal.fdc.models.DataType._
+// import scala.concurrent.Future
+// import scala.util.{Success,Failure}
+
+// // TODO: prevent user from not having any of discreet/mass/volume input 
+// // TODO: Save recipe node in cookie until ready to use
+// object RecipeEdit extends View {
+//   import com.weEat.Main.headers
+
+//   implicit val ec = com.weEat.shared.ctx
+
+//   val tag = "viewRecipe"
+
+//   val title = "Edit Recipe"
+
+//   val ENTER_KEY_CODE = 13
+
+//   override def permissions = Set("user")
+
+//   private val _nameIn = input(idAttr := "name",
+//     cls := "form-control",
+//     required := true
+//   )
+//   private val _name: Signal[String] = _nameIn.value
+
+//   private val internalFoodDS = Dataset(
+//     { _ => Nil},
+//     Some({ (str: String) => FoodController.query(str.toLowerCase)() }),
+//     display = { food: FoodNodeId => food.name }
+//   )
+
+//   private val usdaFoodDS = Dataset(
+//     { _ => Nil},
+//     Some({ str: String =>
+//       USDAController.getFoodsSearch(str, Seq(
+//         Foundation, Survey, SRLegacy
+//       ).map(_.toString))().map(_.foods.map(USDANodeNoId.fromSearchResult))
+//     }),
+//     templates = Some(Templates({x: USDANodeNoId => x.name}).copy(
+//       header = Some({ (_: String, _: Seq[USDANodeNoId], _: String) =>
+//         val html = document.createElement("div").asInstanceOf[HTMLElement]
+//         html.innerText = "Unoffical Foods"
+//         html
+//       })
+//     )),
+//     display = {x: USDANodeNoId => x.name}
+//   )
+
+//   private val _ingredientSearch = input(typ := "text",
+//     cls := "form-control input-sm",
+//     onMountCallback({(ctx) =>
+//       val elm = ctx.thisNode
+//       TypeaheadElement[FoodNode](
+//         elm.ref.asInstanceOf[HTMLInputElement],
+//         minLength = 3
+//       )(Seq(internalFoodDS, usdaFoodDS))
+//       elm.amend(
+//         TypeaheadRx.onSelected[FoodNode] --> { (e: CursorEvent[FoodNode]) =>
+//           e.selectable.map(_.data).foreach({ node =>
+//             // TODO: default unit
+//             _editIngredient(Ingredient.fromFoodNode(node, 0, Gram)) { in =>
+//               _ingredients.update(_ :+ in)
+//               elm.ref.value = ""
+//               Future.successful(())
+//             }
+//           })
+//         }
+//       )
+//     })
+//   )
+
+//   private val _stepIn = input(idAttr := "step",
+//     cls := "form-control",
+//     onKeyPress --> { (e: KeyboardEvent) => if (e.keyCode == ENTER_KEY_CODE) {
+//       val elm = e.target.asInstanceOf[HTMLInputElement]
+//       _steps.update(_ :+ elm.value)
+//       elm.value = ""
+//     } }
+//   )
+
+//   private val _ingredients = Var[Seq[Ingredient]](Nil)
+//   private val _steps = Var[Seq[String]](Nil)
+
+//   private val _servingsIn = input(idAttr := "serv",
+//     typ := "number",
+//     cls := "form-control",
+//     defaultValue := "4",
+//     stepAttr := "0.1",
+//     required := true
+//   )
+//   private val _servings = _servingsIn.value
+
+//   private val _discreetIn = input(idAttr := "discreet",
+//     typ := "checkbox",
+//     cls := "form-check-input",
+//     defaultChecked := true
+//   )
+//   private val _discreet = _discreetIn.checked
+
+//   // TODO: Try autopopulate ingredients.sum(_.massPerUnit)
+//   private val _massPIn = input(idAttr := "massP",
+//     cls := "form-control",
+//     typ := "number",
+//     minAttr := "1",
+//     stepAttr := "0.1",
+//     value := "100"
+//   )
+//   private val _massP = _massPIn.value
+
+//   // TODO: Try autopopulate ingredients.averge(_.density)
+//   private val _volPIn = input(idAttr := "volP",
+//     cls := "form-control",
+//     typ := "number",
+//     minAttr := "0",
+//     stepAttr := "0.1" 
+//   )
+//   private val _volP = _volPIn.value
+
+//   private val _volPUnit = Var[MeasureUnit](Milliliter)
+//   private val _volPUnitIn = select(cls := "custom-select",
+//     MeasureUnit.units.zipWithIndex
+//       .filter({ case (u, _) => u.typ == VOLUME })
+//       .map({ case (unit, idx) =>
+//         // TODO: default selected dynamic
+//         option(
+//           value := idx.toString,
+//           selected := unit.abr == Milliliter.abr,
+//           unit.abr
+//         )
+//       }),
+//     onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> _volPUnit
+//   )
+
+//   val recipieNode = _ingredients.signal.combineWithFn(
+//     _steps,
+//     _name,
+//     _discreet,
+//     _servings,
+//     _volP,
+//     _volPUnit,
+//     _massP
+//   ) { case (ingredients, steps, name, discreet, servings, volP, volPUnit,
+//     massP) =>
+//       val gPServ = massP.toFloatOption
+//       val mLPServ = volP.toFloatOption
+//         .map( _ * volPUnit.conversionRatio )
+//       val (servFactor, servUnit) = gPServ.zip(Some(Gram))
+//         .getOrElse(
+//           if (discreet) (1.0f, Count)
+//           else mLPServ.zip(Some(Milliliter)).getOrElse(???)
+//         ).asInstanceOf[(Float, MeasureUnit)]
+//       val numServings = servings.toFloatOption.getOrElse(1.0f)
+//       val stdQtiesPServing = servFactor / servUnit.typ.standardQuanity
+//       val stdQties = numServings * stdQtiesPServing
+
+//       RecipeNodeNoId(
+//         name,
+//         stdQties,
+//         stdQtiesPServing,
+//         servUnit.typ,
+//         ingredients,
+//         steps,
+//         gPServ.zip(mLPServ).map({ case (m, v) => (m/v).toFloat }),
+//         gPServ
+//       )
+//     }
+
+//   private def _editIngredient(ing: Ingredient)(callback: (Ingredient => Future[_])) = {
+//     val amount = Var[Float](ing.amount)
+//     val unit = Var[MeasureUnit](Gram)
+
+//     Overlay.confirmFuture(ing.food.map({ (food) =>
+//       val amountIn = input(typ := "number",
+//         cls := "form-control input-sm col-9",
+//         minAttr := "0",
+//         defaultValue := ing.amount.toString,
+//         onInput.mapToValue.map(_.toFloat) --> amount
+//       )
+//       val unitIn = select(cls := "col-3 custom-select",
+//         MeasureUnit.units.zipWithIndex.map({ case (unit, idx) =>
+//           // TODO: default selected dynamic
+//           option(value := idx.toString,
+//             selected := unit.abr == Gram.abr,
+//             unit.name
+//           )
+//         }),
+//         onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> unit
+//       )
+
+//       div(cls := "row", amountIn, unitIn)
+//     }),
+//       amount.signal.recoverToTry.map(_.isSuccess) &&
+//         unit.signal.recoverToTry.map(_.isSuccess)
+//     ) { () =>
+//       callback(ing.copy(
+//         amount = amount.now(),
+//         unit = unit.now()
+//       ))
+//     }
+//   }
+
+//   private def _removeFromList[T](s: Seq[T], idx: Int) =
+//     s.take(idx) ++ s.drop(idx + 1)
+
+//   private val _stepList = ol(
+//     listStyleType := "none",
+//     paddingLeft := "0",
+//     children <-- _steps.signal.map(_.zipWithIndex.map((presentStep _).tupled)),
+//     onMountCallback({(ctx) =>
+//       Sortable.create(ctx.thisNode.ref, SortableOptions.onUpdate({
+//       event: SortableEvent => 
+//         _steps.update({ steps =>
+//           (event.oldIndex.toOption, event.newIndex.toOption) match {
+//             case (Some(old), Some(ne)) if (old < ne) =>
+//               moveBackwards(steps, old, ne)
+//             case (Some(old), Some(ne)) =>
+//               moveBackwards(
+//                 steps.reverse,
+//                 steps.size - old - 1,
+//                 steps.size - ne - 1
+//               ).reverse
+//             case (None, Some(ne)) =>
+//               val (first, second) = steps.splitAt(ne)
+//               (first :+ event.item.innerText) ++ second
+//             case (Some(old), None) =>
+//               _removeFromList(steps, old)
+//           }
+//         })
+//         // If the added element is still in the parent, remove it since the
+//         // Rx.update will have generated a new one.
+//         Option(event.item.parentElement).map(_.removeChild(event.item))
+
+//         def moveBackwards(steps: Seq[String], old: Int, ne: Int) =
+//           (steps.take(old) ++
+//             steps.take(ne+1).drop(old + 1) :+
+//             steps(old)) ++
+//             steps.drop(ne+1)
+//       }))
+//     })
+//   )
+
+//   def presentFoodNode(idx: Int)(ingredientSig: Signal[Ingredient]) =
+//     li(children <-- ingredientSig.flatMap { (ingredient: Ingredient) =>
+//       Signal.fromFuture(ingredient.food).optionMap { (food) =>
+//         Seq(
+//           span(cls := "ui-icon ui-icon-pencil",
+//             onClick --> { (e: Event) =>
+//               _editIngredient(ingredient) { (in) =>
+//                 _ingredients.update(_.updated(idx, in))
+//                 Future.successful(())
+//               }
+//             }
+//           ),
+//           span(cls := "ui-icon ui-icon-close",
+//             onClick --> { (e: Event) =>
+//               _ingredients.update(_.filterNot(_ == ingredient))
+//             }
+//           ),
+//           span(f"${ingredient.amount}%.02f${ingredient.unit.abr} ${food.name}")
+//         )
+//       } withDefault(Nil)
+//     })
+
+//   def presentStep(step: String, idx: Int) = li(
+//     span(cls := "ui-icon ui-icon-close",
+//       onClick --> { (e: Event) =>
+//         _steps.update(_removeFromList(_, idx))
+//       }
+//     ),
+//     step
+//   )
+
+//   def content = div(
+//     h2("Recipe Editor"),
+//     div(cls := "form-group",
+//       div(cls := "container",
+//         div(cls := "row",
+//           div(cls := "col-md-12",
+//             label("Name: "),
+//             _nameIn
+//           )
+//         ),
+//         div(cls := "row",
+//           div(cls := "col-md-3 form-check",
+//             div(cls := "form-check",
+//               _discreetIn,
+//               label(cls := "form-check-label", 
+//                 forId := "discreet",
+//                 "Descrete Servings"
+//               )
+//             )
+//           ),
+//           div(cls := "col-md-3 input-group",
+//             _servingsIn,
+//             div(cls := "input-group-append",
+//               label(cls := "input-group-text", "Servings")
+//             )
+//           ),
+//           div(cls := "col-md-3 input-group",
+//             _massPIn,
+//             div(cls := "input-group-append",
+//               label(cls := "input-group-text", "g/Serving")
+//             )
+//           ),
+//           div(cls := "col-md-3 input-group",
+//             _volPIn,
+//             _volPUnitIn,
+//             div(cls := "input-group-append",
+//               label(cls := "input-group-text", "/Serving"),
+//             ),
+//           )
+//         ),
+//         div(cls := "row",
+//           div(cls := "col-md-5",
+//             h2("Ingredients"),
+//             _ingredientSearch,
+//             ul(
+//               listStyleType := "none",
+//               paddingLeft := "0",
+//               children <-- _ingredients.signal.splitByIndex {
+//                 case (idx, _, ingredientStream) =>
+//                   presentFoodNode(idx)(ingredientStream)
+//               }
+//             )
+//           ),
+//           div(cls := "col-md-5",
+//             h2("Steps"),
+//             _stepIn,
+//             _stepList
+//           ),
+//           div(cls := "col-md-2",
+//             NutritionPane(recipieNode).render
+//           )
+//         )
+//       ),
+//       button(cls := "btn",
+//         onClick --> { (e: Event) =>
+//           implicit val owner = new ManualOwner()
+
+//           // import play.api.libs.json.Json
+//           // println(recipieNode.value)
+//           // println(Json.toJson(recipieNode.value))
+//           // println(Json.stringify(Json.toJson(recipieNode.value)))
+//           FoodController.add()(recipieNode.observe.now()).onComplete {
+//             case Success(_) => println("Success!")
+//             case Failure(ex) =>
+//               println("Could not add recipe")
+//               throw ex
+//           }
+//         },
+//         "Add"
+//       )
+//     )
+//   )
+// }

+ 137 - 0
webClient/src/main/scala/com/weEat/views/UsdaImporter.scala

@@ -0,0 +1,137 @@
+package com.weEat.view
+
+import com.raquo.laminar.api.L._
+import io.laminext.syntax.core._
+import com.raquo.waypoint._
+
+import play.api.libs.json.{JsValue,Json}
+import com.weEat.controllers.{FoodController,USDAController}
+import com.weEat.modules._
+import com.weEat.shared.models.USDANodeNoId
+import gov.usda.nal.fdc.models._
+import gov.usda.nal.fdc.models.DataType._
+import org.scalajs.dom.Event
+import scala.concurrent.Future
+import scala.util.Success
+
+object UsdaImporter extends View[Option[String]] {
+  import com.weEat.Main.headers
+
+  implicit val ctx = com.weEat.shared.ctx
+
+  val navName = "Usda Import"
+  val tag = "importer"
+
+  override val permissions = Set("admin")
+
+  case class ViewPage(val q: Option[String] = None) extends P {
+    val title = "USDA Import" + q.map((q) => s" - $q").getOrElse("")
+    def jsonValue = Json.toJson(q)
+  }
+  def parseJson(jsVal: JsValue) = ViewPage(jsVal.asOpt[String])
+  def route = Route.onlyQuery(
+    encode = (page: ViewPage) => page.q,
+    decode = (q: Option[String]) => ViewPage(q = q),
+    pattern = (root / tag / endOfSegments) ? param[String]("q").?
+  )
+  def defaultPage = ViewPage()
+
+  import com.raquo.airstream.custom.CustomSource._
+  def lazyFutureSignal[T](fut: => Future[T]) =
+    Signal.fromCustomSource(
+      Success(fut),
+      (_: SetCurrentValue[Future[T]], _: GetCurrentValue[Future[T]], _, _) => (),
+      (_) => ()
+    ).flatMap { (fut) =>
+      Signal.fromFuture(fut)
+    }
+  def lazyFutureSignal[T](fut: => Future[T], default: => T) =
+    Signal.fromCustomSource(
+      Success(fut),
+      (_: SetCurrentValue[Future[T]], _: GetCurrentValue[Future[T]], _, _) => (),
+      (_) => ()
+    ).flatMap { (fut) =>
+      Signal.fromFuture(fut)
+    }
+
+  private val SEARCH_PAGE_SIZE = 40.asInstanceOf[Short]
+
+  def content(queryPage: Signal[ViewPage]) = {
+    val searchBar: SearchBar[Seq[Signal[Option[Seq[SearchResultFood]]]]] =
+      SearchBar((term) =>
+        USDAController.getFoodsSearch(term, Seq(
+          Foundation, Survey, SRLegacy
+        ).map(_.toString), pageSize = Some(SEARCH_PAGE_SIZE))().map {
+          case SearchResult(criteria, n, cur, tot, baseList) =>
+            Val(Some(baseList)) +:
+              (cur + 1 to tot).map({ (c) => lazyFutureSignal(
+                USDAController.postFoodsSearch()(
+                  criteria.copy(pageNumber = Some(c))
+                ).map(_.foods),
+                Nil
+              ) })
+        },
+        inSignal = queryPage.map(_.q).withDefault("")
+      )
+    val searchResults = searchBar.result
+    val pageSizeSel = Select(Val((10 to SEARCH_PAGE_SIZE by 10)))
+    val numPages = pageSizeSel.value.combineWithFn(searchResults) {
+      case (_, None) => 0
+      case (size, Some(results)) => results.length * SEARCH_PAGE_SIZE / size
+    }
+    val pageSel = PageSelect(numPages)
+    val currentSearchPageNum = pageSel.page.combineWithFn(pageSizeSel.value) {
+      case (pageNum, pageSize) => pageNum * pageSize / SEARCH_PAGE_SIZE
+    }
+    val currentSearchPageOffset = pageSel.page.combineWithFn(pageSizeSel.value) {
+      case (pageNum, pageSize) => pageNum % (SEARCH_PAGE_SIZE / pageSize)
+    }
+    val currentSearchPage = searchResults.combineWithFn(currentSearchPageNum) {
+      case (Some(Nil), _) => Val(Nil)
+      case (None, _) => Val(Nil)
+      // [2023-09-26]@tflucke TODO: implement loading UI instead of default
+      case (Some(res), pageNum) => res(pageNum).withDefault(Nil)
+    }.flatMap(identity)
+    // TODO: Prefetch next page
+
+    div(
+      h2("USDA Importer"),
+      div(cls := "form-group",
+        label(forId := "search", "Search: "),
+        searchBar.render
+      ),
+      searchBar.searchTerm --> { (str: Option[String]) =>
+        val oldQ = View.router.currentPageSignal.now().asInstanceOf[ViewPage].q
+        if (oldQ != str)
+          View.router.pushState(ViewPage(q = str))
+      },
+      PaginatedTable[SearchResultFood](Seq(
+        ("", 1, { (x) => button(cls := "btn btn-light",
+          onClick --> {(e: Event) =>
+            // defaultUnit = if (num().nonEmpty) NUMBER else MASS
+            val editor = USDAEditor(USDANodeNoId.fromSearchResult(x), true)
+            Overlay.confirm(editor.render) { () =>
+              import com.weEat.Main.headers
+              FoodController.add()(editor.getUSDANode())
+            }
+          },
+          "Add"
+        )}),
+        ("ID", 1, { (x) => span(x.fdcId.toString)}),
+        ("Name", 3, { (x) => span(x.description)}),
+        ("Desc", 4, { (x) => span(x.additionalDescriptions)}),
+        ("Brand", 3, { (x) => span(x.brandOwner)})
+      ),
+        currentSearchPage,
+        currentSearchPageOffset,
+        pageSizeSel.value
+      ).render,
+      (pageSel.render).amendThis(
+        (elm) => cls := s"${elm.ref.className} col-3 float-left"
+      ),
+      (pageSizeSel.render).amendThis(
+        (elm) => cls := s"${elm.ref.className} col-1 float-right"
+      )
+    )
+  }
+}

+ 17 - 0
webClient/src/main/scala/com/weEat/views/UserManage.scala

@@ -0,0 +1,17 @@
+// package com.weEat.view
+
+// import com.raquo.laminar.api.L._
+
+// object UserManage extends View {
+//   implicit val ctx = com.weEat.shared.ctx
+
+//   val tag = "umanage"
+
+//   val title = "Manage Users"
+
+//   override val permissions = Set("admin")
+
+//   def content = div(
+//     h2("Manage Users")
+//   )
+// }

+ 82 - 0
webClient/src/main/scala/com/weEat/views/View.scala

@@ -0,0 +1,82 @@
+package com.weEat.view
+
+import com.raquo.laminar.api.L._
+import com.raquo.waypoint._
+import play.api.libs.json._
+import com.weEat.shared.OAuthManager
+
+trait View[Args] {
+  type ViewPage <: P
+
+  val navName: String
+  def tag: String
+  def content(s: Signal[ViewPage]): com.raquo.laminar.nodes.ReactiveElement.Base
+  def permissions: Set[String] = Set.empty
+  def route: Route[_ <: P, Args]
+  def parseJson(jsVal: JsValue): P
+  trait P extends View.Page {
+    final def t = tag
+    final def asJson: JsObject = JsObject(Map(
+      "t" -> JsString(tag),
+      "v" -> jsonValue
+    ))
+  }
+  def defaultPage: ViewPage
+}
+
+object View {
+  trait Page {
+    def t: String
+    def jsonValue: JsValue
+    def title: String
+    def asJson: JsObject
+  }
+
+  def home = FoodSearch
+  val titleSeperator = " - "
+
+  def index: Seq[View[_]] = Seq(
+    FoodSearch,
+    UsdaImporter,
+    RecipeEdit,
+    //RecipeView,
+    // UserManage,
+    //ProfileView,
+    //ProfileEdit
+  )
+
+  private def _currentFilteredIndex = {
+    val curScope = OAuthManager.currentScope
+    index.filter(_.permissions.forall(curScope.contains))
+  }
+  private val _filteredIndex = Var(_currentFilteredIndex)
+  OAuthManager.addObserver({() => _filteredIndex.set(_currentFilteredIndex)})
+  
+  def authedIndex = _filteredIndex.signal
+
+  val router = new Router[Page](
+    routes = index.map(_.route).toList,
+    getPageTitle = _.title,
+    serializePage = (page) => Json.stringify(page.asJson),
+    deserializePage = { (pageStr) =>
+      Json.parse(pageStr) match {
+        case obj: JsObject =>
+          _currentFilteredIndex
+            .find(_.tag == (obj \ "t").get.as[String])
+            .getOrElse({???})
+            .parseJson(obj.value("v"))
+        case x => println(x);???
+      }
+    }
+  )(
+    windowEvents(_.onPopState),
+    unsafeWindowOwner
+  )
+
+  val splitter = SplitRender[Page, HtmlElement](router.currentPageSignal)
+    .collectSignal[UsdaImporter.ViewPage] { (page) => UsdaImporter.content(page) }
+    .collectSignal[FoodSearch.ViewPage] { (page) => FoodSearch.content(page) }
+    .collectSignal[RecipeEdit.ViewPage] { (page) => RecipeEdit.content(page) }
+
+}
+