First commit
This commit is contained in:
commit
dc1bb4ad31
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
target/
|
||||||
|
.idea/
|
||||||
|
.metals
|
||||||
|
metals.sbt
|
||||||
|
.vscode
|
||||||
|
.bsp
|
||||||
|
.bloop
|
1
.scalafmt.conf
Normal file
1
.scalafmt.conf
Normal file
@ -0,0 +1 @@
|
|||||||
|
version = "2.7.4"
|
23
README.md
Normal file
23
README.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
Outwatch-Test
|
||||||
|
====
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
You should make sure that the following components are pre-installed on your machine:
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/en/download/)
|
||||||
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
|
|
||||||
|
## Create a module
|
||||||
|
in sbt shell: `fastOptJS::webpack` or `fullOptJS::webpack`
|
||||||
|
|
||||||
|
## Working in dev mode
|
||||||
|
In sbt shell, run `dev`. Then open `http://localhost:8080/` in your browser.
|
||||||
|
|
||||||
|
This sbt-task will start webpack dev server, compile your code each time it changes
|
||||||
|
and auto-reload the page.
|
||||||
|
webpack dev server will close automatically when you stop the `dev` task
|
||||||
|
(e.g by hitting `Enter` in the sbt shell while you are in `dev` watch mode).
|
||||||
|
|
||||||
|
If you existed ungracefully and your webpack dev server is still open (check with `ps -aef | grep -v grep | grep webpack`),
|
||||||
|
you can close it by running `fastOptJS::stopWebpackDevServer` in sbt.
|
25
assets/app/index.html
Normal file
25
assets/app/index.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Outwatch App</title>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js" integrity="sha512-bLT0Qm9VnAYZDflyKcBaQ2gg0hSYNQrJ8RilYldYQ1FxQYoCLtUjuuRuZo+fjqhx/qtq/1itJ0C2ejDxltZVFg==" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.3/js/bootstrap.bundle.min.js" integrity="sha512-iceXjjbmB2rwoX93Ka6HAHP+B76IY1z0o3h+N1PeDtRSsyeetU3/0QKJqGyPJcX63zysNehggFwMC/bi7dvMig==" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/blk-design-system@1.0.2/assets/js/blk-design-system.min.js" integrity="sha256-0+0XIgwF6mAu4/UMvl0Eh9tvz2CLhNCo1taXQlpeyqg=" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.3/css/bootstrap.min.css" integrity="sha512-oc9+XSs1H243/FRN9Rw62Fn8EtxjEYWHXRvjS43YtueEewbS6ObfXcJNyohjHqVKFPoXXUxwc+q1K7Dee6vv9g==" crossorigin="anonymous" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/blk-design-system@1.0.2/assets/css/blk-design-system.min.css" integrity="sha256-6HRu9ZigEQ/feV+C1JxZyZSmMcqQ8Hymukmjdk4nl9A=" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/blk-design-system@1.0.2/assets/css/nucleo-icons.css" integrity="sha256-03+9B37/His+rzjhgA6Y1+ByU9DGN2ZPWjjA5CJJF2w=" crossorigin="anonymous">
|
||||||
|
|
||||||
|
<!-- <link rel="stylesheet" href="../css/demo.css"> -->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<script type="text/javascript" src="../outwatchapp-fastopt-library.js"></script>
|
||||||
|
<script type="text/javascript" src="../outwatchapp-fastopt-loader.js"></script>
|
||||||
|
<script type="text/javascript" src="../outwatchapp-fastopt.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
13
assets/index.html
Normal file
13
assets/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Outwatch-Test</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<a href="app/#">Go To App</a>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
125
build.sbt
Normal file
125
build.sbt
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
//organization := "wow.doge"
|
||||||
|
//name := "Outwatchtest"
|
||||||
|
//version := "0.1.0"
|
||||||
|
|
||||||
|
name := "OutwatchApp"
|
||||||
|
|
||||||
|
scalaVersion := "2.13.4"
|
||||||
|
resolvers += "jitpack" at "https://jitpack.io"
|
||||||
|
|
||||||
|
libraryDependencies ++= Seq(
|
||||||
|
"com.github.outwatch.outwatch" %%% "outwatch" % "61deece8",
|
||||||
|
"com.github.outwatch.outwatch" %%% "outwatch-util" % "master-SNAPSHOT",
|
||||||
|
"com.github.cornerman.colibri" %%% "colibri-monix" % "master-SNAPSHOT",
|
||||||
|
"com.github.outwatch.outwatch" %%% "outwatch-monix" % "master-SNAPSHOT",
|
||||||
|
"org.scalatest" %%% "scalatest" % "3.2.0" % Test,
|
||||||
|
"org.typelevel" %%% "cats-core" % "2.1.1",
|
||||||
|
"org.typelevel" %%% "cats-effect" % "2.1.4",
|
||||||
|
"io.monix" %%% "monix" % "3.2.2",
|
||||||
|
"io.monix" %%% "monix-bio" % "1.1.0",
|
||||||
|
"io.circe" %%% "circe-core" % "0.13.0",
|
||||||
|
"io.circe" %%% "circe-generic" % "0.13.0",
|
||||||
|
"com.softwaremill.sttp.client" %%% "core" % "2.2.5",
|
||||||
|
"com.softwaremill.sttp.client" %%% "monix" % "2.2.5",
|
||||||
|
"com.softwaremill.sttp.client" %%% "circe" % "2.2.5",
|
||||||
|
// "com.softwaremill.sttp.client" %%% "async-http-client-backend-monix" % "2.2.5",
|
||||||
|
// "com.softwaremill.sttp.client3" %%% "httpclient-backend-monix" % "2.2.5",
|
||||||
|
// "com.softwaremill.macwire" %%% "util" % "2.3.7",
|
||||||
|
"com.softwaremill.macwire" %% "macros" % "2.3.7" % "provided",
|
||||||
|
// "com.softwaremill.macwire" %%% "macrosakka" % "2.3.6" % "provided",
|
||||||
|
"com.softwaremill.quicklens" %%% "quicklens" % "1.6.1",
|
||||||
|
// "com.typesafe.scala-logging" %%% "scala-logging" % "3.9.2",
|
||||||
|
// "io.circe" %%% "circe-config" % "0.8.0",
|
||||||
|
"org.akka-js" %%% "shocon" % "1.0.0",
|
||||||
|
"com.beachape" %%% "enumeratum-circe" % "1.6.1"
|
||||||
|
// "com.clovellytech" %%% "outwatch-router" % "0.0.9+7-5be0b1a2+20201227-2019-SNAPSHOT"
|
||||||
|
)
|
||||||
|
|
||||||
|
Compile / npmDependencies ++= Seq(
|
||||||
|
// "jquery" -> "1.9.1",
|
||||||
|
// "popper.js" -> "1.16.1",
|
||||||
|
// // "@popperjs/core" -> "2.6.0",
|
||||||
|
// "blk-design-system" -> "1.0.2",
|
||||||
|
// "bootstrap" -> "4.5.3"
|
||||||
|
"snabbdom" -> "git://github.com/outwatch/snabbdom.git#semver:0.7.5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile / npmDevDependencies ++= Seq(
|
||||||
|
// "css-loader" -> "5.0.1",
|
||||||
|
// "style-loader" -> "2.0.0"
|
||||||
|
// )
|
||||||
|
|
||||||
|
// stIgnore ++= List("jquery", "blk-design-system", "bootstrap")
|
||||||
|
stIgnore ++= List("snabbdom")
|
||||||
|
|
||||||
|
enablePlugins(ScalaJSBundlerPlugin)
|
||||||
|
enablePlugins(ScalablyTypedConverterPlugin)
|
||||||
|
useYarn := true // makes scalajs-bundler use yarn instead of npm
|
||||||
|
requireJsDomEnv in Test := true
|
||||||
|
scalaJSUseMainModuleInitializer := true
|
||||||
|
// configure Scala.js to emit a JavaScript module instead of a top-level script
|
||||||
|
|
||||||
|
scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.CommonJSModule))
|
||||||
|
scalacOptions ++=
|
||||||
|
Seq(
|
||||||
|
"-encoding",
|
||||||
|
"UTF-8",
|
||||||
|
"-deprecation",
|
||||||
|
"-feature",
|
||||||
|
"-language:existentials",
|
||||||
|
"-language:experimental.macros",
|
||||||
|
"-language:higherKinds",
|
||||||
|
"-language:implicitConversions",
|
||||||
|
"-unchecked",
|
||||||
|
"-Xlint",
|
||||||
|
"-Ywarn-numeric-widen",
|
||||||
|
"-Ymacro-annotations",
|
||||||
|
//silence warnings for by-name implicits
|
||||||
|
"-Wconf:cat=lint-byname-implicit:s",
|
||||||
|
//give errors on non exhaustive matches
|
||||||
|
"-Wconf:msg=match may not be exhaustive:e",
|
||||||
|
"-explaintypes" // Explain type errors in more detail.
|
||||||
|
)
|
||||||
|
|
||||||
|
// hot reloading configuration:
|
||||||
|
// https://github.com/scalacenter/scalajs-bundler/issues/180
|
||||||
|
addCommandAlias(
|
||||||
|
"dev",
|
||||||
|
"; compile; fastOptJS::startWebpackDevServer; devwatch; fastOptJS::stopWebpackDevServer"
|
||||||
|
)
|
||||||
|
addCommandAlias("devwatch", "~; fastOptJS; copyFastOptJS")
|
||||||
|
|
||||||
|
version in webpack := "4.43.0"
|
||||||
|
version in startWebpackDevServer := "3.11.0"
|
||||||
|
webpackDevServerExtraArgs := Seq("--progress", "--color")
|
||||||
|
webpackDevServerPort := 8080
|
||||||
|
webpackConfigFile in fastOptJS := Some(
|
||||||
|
baseDirectory.value / "webpack.config.dev.js"
|
||||||
|
)
|
||||||
|
// webpackConfigFile in fullOptJS := Some(
|
||||||
|
// baseDirectory.value / "webpack.config.js"
|
||||||
|
// )
|
||||||
|
|
||||||
|
// https://scalacenter.github.io/scalajs-bundler/cookbook.html#performance
|
||||||
|
webpackBundlingMode in fastOptJS := BundlingMode.LibraryOnly()
|
||||||
|
|
||||||
|
// when running the "dev" alias, after every fastOptJS compile all artifacts are copied into
|
||||||
|
// a folder which is served and watched by the webpack devserver.
|
||||||
|
// this is a workaround for: https://github.com/scalacenter/scalajs-bundler/issues/180
|
||||||
|
lazy val copyFastOptJS =
|
||||||
|
TaskKey[Unit]("copyFastOptJS", "Copy javascript files to target directory")
|
||||||
|
copyFastOptJS := {
|
||||||
|
val inDir = (crossTarget in (Compile, fastOptJS)).value
|
||||||
|
val outDir = (crossTarget in (Compile, fastOptJS)).value / "dev"
|
||||||
|
val files = Seq(
|
||||||
|
name.value.toLowerCase + "-fastopt-loader.js",
|
||||||
|
name.value.toLowerCase + "-fastopt.js",
|
||||||
|
name.value.toLowerCase + "-fastopt.js.map"
|
||||||
|
) map { p => (inDir / p, outDir / p) }
|
||||||
|
IO.copy(
|
||||||
|
files,
|
||||||
|
overwrite = true,
|
||||||
|
preserveLastModified = true,
|
||||||
|
preserveExecutable = true
|
||||||
|
)
|
||||||
|
}
|
1
project/build.properties
Normal file
1
project/build.properties
Normal file
@ -0,0 +1 @@
|
|||||||
|
sbt.version=1.4.6
|
4
project/plugins.sbt
Normal file
4
project/plugins.sbt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.3.0")
|
||||||
|
addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.20.0")
|
||||||
|
resolvers += Resolver.bintrayRepo("oyvindberg", "converter")
|
||||||
|
addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta29.1")
|
145
src/main/scala/outwatch/router/Router.scala
Normal file
145
src/main/scala/outwatch/router/Router.scala
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
package outwatch.router
|
||||||
|
|
||||||
|
import org.scalajs.dom.window
|
||||||
|
import colibri.Observable
|
||||||
|
import outwatch.util.Store
|
||||||
|
import outwatch.util.Reducer
|
||||||
|
import cats.effect.Sync
|
||||||
|
import outwatch._
|
||||||
|
import outwatch.dsl._
|
||||||
|
import outwatch.router.dsl.Path
|
||||||
|
import outwatch.router.dsl.HashPath
|
||||||
|
import outwatch.router.dsl.Root
|
||||||
|
|
||||||
|
sealed trait Action
|
||||||
|
final case class Replace(path: Path) extends Action
|
||||||
|
final case class HistoryEvent(path: Path) extends Action
|
||||||
|
|
||||||
|
final case class RouterState[P](page: P)
|
||||||
|
|
||||||
|
/** An AppRouter handles parsing of URLs and mapping to pages of the given type.
|
||||||
|
* @param siteRoot - The prefix part of a pathname, or the subpath at which your site is applied.
|
||||||
|
* Usually this is just Root, but your site might need a prefix as in /my_site/[parsed pathname]
|
||||||
|
* @param parent - The parent path at which this router is mounted. You can have routers contained in subroots of your site.
|
||||||
|
* @param f - a mapping function from a Path to a page P.
|
||||||
|
* @tparam F - the effect type
|
||||||
|
* @tparam P - Your page type, such as a sealed trait root type.
|
||||||
|
*/
|
||||||
|
class AppRouter[F[_]: Sync, P](siteRoot: Path, parent: Path, f: Path => P) {
|
||||||
|
// Sync from the required page to the window.location
|
||||||
|
def routerReducer(state: RouterState[P], action: Action): RouterState[P] =
|
||||||
|
action match {
|
||||||
|
case Replace(path) =>
|
||||||
|
println(s"Path = $path")
|
||||||
|
window.history.pushState(
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
HashPath(siteRoot, HashPath(parent, path)).toUrlString
|
||||||
|
)
|
||||||
|
state.copy(page = f(path))
|
||||||
|
case HistoryEvent(path) =>
|
||||||
|
println(s"Path = $path")
|
||||||
|
state.copy(page = f(path))
|
||||||
|
case _ => state
|
||||||
|
}
|
||||||
|
|
||||||
|
def store: F[RouterStore[P]] = {
|
||||||
|
val startingPath = HashPath(window.location.hash.substring(1))
|
||||||
|
|
||||||
|
Store.create[F, Action, RouterState[P]](
|
||||||
|
Replace(startingPath),
|
||||||
|
RouterState(f(startingPath)),
|
||||||
|
Reducer(routerReducer _)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def link(
|
||||||
|
linkHref: String
|
||||||
|
)(attrs: VDomModifier*)(implicit store: RouterStore[P]): BasicVNode =
|
||||||
|
a(href := linkHref)(
|
||||||
|
onClick.preventDefault.useLazy(
|
||||||
|
Replace(HashPath(linkHref))
|
||||||
|
) --> store.sink,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
def render(resolver: RouterResolve[P])(implicit
|
||||||
|
store: RouterStore[P]
|
||||||
|
): Observable[VDomModifier] =
|
||||||
|
store.map { case (_, RouterState(p)) => resolver(p) }
|
||||||
|
|
||||||
|
def watch()(implicit store: RouterStore[P]) =
|
||||||
|
emitter(outwatch.dsl.events.window.onPopState).useLazy(
|
||||||
|
HistoryEvent(HashPath(org.scalajs.dom.window.location.hash.substring(1)))
|
||||||
|
) --> store.sink
|
||||||
|
|
||||||
|
// def link(
|
||||||
|
// p: P
|
||||||
|
// )(attrs: VDomModifier*)(implicit store: RouterStore[P]): BasicVNode =
|
||||||
|
// a(href := p.toString())(
|
||||||
|
// onClick.preventDefault.useLazy(
|
||||||
|
// Replace(Path(p.toString()))
|
||||||
|
// ) --> store.sink,
|
||||||
|
// attrs
|
||||||
|
// )
|
||||||
|
}
|
||||||
|
|
||||||
|
object AppRouter {
|
||||||
|
def render[P](resolver: RouterResolve[P])(implicit
|
||||||
|
store: RouterStore[P]
|
||||||
|
): Observable[VDomModifier] =
|
||||||
|
store.map { case (_, RouterState(p)) => resolver(p) }
|
||||||
|
|
||||||
|
def watch[P]()(implicit store: RouterStore[P]) =
|
||||||
|
emitter(
|
||||||
|
outwatch.dsl.events.window.onPopState
|
||||||
|
// .doOnNext(e =>
|
||||||
|
// println(
|
||||||
|
// s"changed ${org.scalajs.dom.window.location.toString()}"
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
).useLazy(
|
||||||
|
HistoryEvent(Path(org.scalajs.dom.window.location.pathname))
|
||||||
|
) --> store.sink
|
||||||
|
|
||||||
|
def create[F[_]: Sync, P](notFound: P)(
|
||||||
|
f: PartialFunction[Path, P]
|
||||||
|
): AppRouter[F, P] =
|
||||||
|
create[F, P](Root, notFound)(f)
|
||||||
|
|
||||||
|
def create[F[_]: Sync, P](parent: Path, notFound: P)(
|
||||||
|
f: PartialFunction[Path, P]
|
||||||
|
): AppRouter[F, P] =
|
||||||
|
new AppRouter[F, P](Root, parent, f.lift.andThen(_.getOrElse(notFound)))
|
||||||
|
|
||||||
|
def createParseSiteRoot[F[_]: Sync, P](notFound: P)(
|
||||||
|
f: PartialFunction[Path, P]
|
||||||
|
): AppRouter[F, P] =
|
||||||
|
createParseSiteRoot[F, P](Root, notFound)(f)
|
||||||
|
|
||||||
|
/** Automatically determine what siteroot we're using, based on the current URL and expected parent.
|
||||||
|
* For example, your site could be deployed at /example/directory/, your router root path could be /names,
|
||||||
|
* and the current url could be /example/directory/names/alice. So given the call:
|
||||||
|
* createParseSubRoot[Page](Path("/names"), NotFound)(f), the router will work out that the window location
|
||||||
|
* prefix must be /example/directory/names, and will handle actions such as Replace(Path("/names/bob"))
|
||||||
|
* @param parent the parent for this router, another path perhaps managed by another router
|
||||||
|
* @param notFound - the default case page assignment
|
||||||
|
* @param f - a router function from Path to instances of your page type
|
||||||
|
* @tparam P - your page type
|
||||||
|
*/
|
||||||
|
def createParseSiteRoot[F[_]: Sync, P](parent: Path, notFound: P)(
|
||||||
|
f: PartialFunction[Path, P]
|
||||||
|
): AppRouter[F, P] = {
|
||||||
|
val initUrl = window.location.pathname
|
||||||
|
// url is of form /sra/srb/src/pa/pb/pc...
|
||||||
|
// so just drop the parent part from the right of the url if it exists.
|
||||||
|
|
||||||
|
val siteRoot = initUrl.lastIndexOf(parent.toString) match {
|
||||||
|
case x if x < 1 => Root
|
||||||
|
case x => Path(initUrl.substring(0, x))
|
||||||
|
}
|
||||||
|
|
||||||
|
val routerFun: Path => P = f.lift.andThen(_.getOrElse(notFound))
|
||||||
|
new AppRouter[F, P](siteRoot, parent, routerFun)
|
||||||
|
}
|
||||||
|
}
|
10
src/main/scala/outwatch/router/dsl/Extractors.scala
Normal file
10
src/main/scala/outwatch/router/dsl/Extractors.scala
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package outwatch.router.dsl
|
||||||
|
|
||||||
|
trait Extractors {
|
||||||
|
object IntVar {
|
||||||
|
def unapply(str: String): Option[Int] = {
|
||||||
|
if (!str.isEmpty) str.toIntOption
|
||||||
|
else None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
294
src/main/scala/outwatch/router/dsl/Path.scala
Normal file
294
src/main/scala/outwatch/router/dsl/Path.scala
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
// Completely copied from org.http4s.dsl.impl
|
||||||
|
|
||||||
|
package outwatch.router.dsl
|
||||||
|
|
||||||
|
import cats.implicits._
|
||||||
|
import java.nio.{ByteBuffer, CharBuffer}
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
import java.nio.charset.StandardCharsets.UTF_8
|
||||||
|
|
||||||
|
/** Base class for path extractors. */
|
||||||
|
trait Path {
|
||||||
|
def /(child: String): Path = new /(this, child)
|
||||||
|
def toList: List[String]
|
||||||
|
def parent: Path
|
||||||
|
def lastOption: Option[String]
|
||||||
|
def startsWith(other: Path): Boolean
|
||||||
|
def pathString: String
|
||||||
|
def toUrlString: String = if (pathString.isEmpty) "" else pathString
|
||||||
|
}
|
||||||
|
|
||||||
|
trait HashPath extends Path {
|
||||||
|
override def toUrlString: String = if (pathString.isEmpty) "#" else pathString
|
||||||
|
}
|
||||||
|
|
||||||
|
object HashPath {
|
||||||
|
def apply(str: String): Path =
|
||||||
|
if (str == "" || str == "/" || str == "#")
|
||||||
|
Root
|
||||||
|
else {
|
||||||
|
val segments = str.split("/", -1)
|
||||||
|
// .head is safe because split always returns non-empty array
|
||||||
|
val segments0 = if (segments.head == "") segments.drop(1) else segments
|
||||||
|
segments0.foldLeft(Root: Path)((path, seg) =>
|
||||||
|
path / UrlCodingUtils.urlDecode(seg)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def apply(first: String, rest: String*): Path =
|
||||||
|
rest.foldLeft(Root / first)(_ / _)
|
||||||
|
|
||||||
|
def apply(list: List[String]): Path =
|
||||||
|
list.foldLeft(Root: Path)(_ / _)
|
||||||
|
|
||||||
|
def apply(left: Path, right: Path): Path =
|
||||||
|
right.toList.foldLeft(left)(_ / _)
|
||||||
|
|
||||||
|
def unapplySeq(path: Path): Some[List[String]] =
|
||||||
|
Some(path.toList)
|
||||||
|
}
|
||||||
|
|
||||||
|
object Path {
|
||||||
|
|
||||||
|
/** Constructs a path from a single string by splitting on the `'/'`
|
||||||
|
* character.
|
||||||
|
*
|
||||||
|
* Leading slashes do not create an empty path segment. This is to
|
||||||
|
* reflect that there is no distinction between a request to
|
||||||
|
* `http://www.example.com` from `http://www.example.com/`.
|
||||||
|
*
|
||||||
|
* Trailing slashes result in a path with an empty final segment,
|
||||||
|
* unless the path is `"/"`, which is `Root`.
|
||||||
|
*
|
||||||
|
* Segments are URL decoded.
|
||||||
|
*
|
||||||
|
* {{{
|
||||||
|
* scala> Path("").toList
|
||||||
|
* res0: List[String] = List()
|
||||||
|
* scala> Path("/").toList
|
||||||
|
* res1: List[String] = List()
|
||||||
|
* scala> Path("a").toList
|
||||||
|
* res2: List[String] = List(a)
|
||||||
|
* scala> Path("/a").toList
|
||||||
|
* res3: List[String] = List(a)
|
||||||
|
* scala> Path("/a/").toList
|
||||||
|
* res4: List[String] = List(a, "")
|
||||||
|
* scala> Path("//a").toList
|
||||||
|
* res5: List[String] = List("", a)
|
||||||
|
* scala> Path("/%2F").toList
|
||||||
|
* res0: List[String] = List(/)
|
||||||
|
* }}}
|
||||||
|
*/
|
||||||
|
def apply(str: String): Path =
|
||||||
|
if (str == "" || str == "/")
|
||||||
|
Root
|
||||||
|
else {
|
||||||
|
val segments = str.split("/", -1)
|
||||||
|
// .head is safe because split always returns non-empty array
|
||||||
|
val segments0 = if (segments.head == "") segments.drop(1) else segments
|
||||||
|
segments0.foldLeft(Root: Path)((path, seg) =>
|
||||||
|
path / UrlCodingUtils.urlDecode(seg)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def apply(first: String, rest: String*): Path =
|
||||||
|
rest.foldLeft(Root / first)(_ / _)
|
||||||
|
|
||||||
|
def apply(list: List[String]): Path =
|
||||||
|
list.foldLeft(Root: Path)(_ / _)
|
||||||
|
|
||||||
|
def apply(left: Path, right: Path): Path =
|
||||||
|
right.toList.foldLeft(left)(_ / _)
|
||||||
|
|
||||||
|
def unapplySeq(path: Path): Some[List[String]] =
|
||||||
|
Some(path.toList)
|
||||||
|
//
|
||||||
|
// def unapplySeq[F[_]](request: Request[F]): Some[List[String]] =
|
||||||
|
// Some(Path(request.pathInfo).toList)
|
||||||
|
}
|
||||||
|
|
||||||
|
final case class /(parent: Path, child: String) extends Path {
|
||||||
|
lazy val toList: List[String] = parent.toList ++ List(child)
|
||||||
|
|
||||||
|
def lastOption: Some[String] = Some(child)
|
||||||
|
|
||||||
|
lazy val asString: String =
|
||||||
|
s"${parent.pathString}/${UrlCodingUtils.pathEncode(child)}"
|
||||||
|
|
||||||
|
override def toString: String = asString
|
||||||
|
|
||||||
|
def pathString: String = asString
|
||||||
|
|
||||||
|
def startsWith(other: Path): Boolean = {
|
||||||
|
val components = other.toList
|
||||||
|
toList.take(components.length) === components
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final case class Blank(parent: Path, child: String) extends Path {
|
||||||
|
lazy val toList: List[String] = parent.toList ++ List(child)
|
||||||
|
|
||||||
|
def lastOption: Some[String] = Some(child)
|
||||||
|
|
||||||
|
lazy val asString: String =
|
||||||
|
s"${parent.pathString}${UrlCodingUtils.pathEncode(child)}"
|
||||||
|
|
||||||
|
override def toString: String = asString
|
||||||
|
|
||||||
|
def pathString: String = asString
|
||||||
|
|
||||||
|
def startsWith(other: Path): Boolean = {
|
||||||
|
val components = other.toList
|
||||||
|
toList.take(components.length) === components
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path separator extractor:
|
||||||
|
* {{{
|
||||||
|
* Path("/1/2/3/test.json") match {
|
||||||
|
* case "1" /: "2" /: _ => ...
|
||||||
|
* }}}
|
||||||
|
*/
|
||||||
|
object /: {
|
||||||
|
def unapply(path: Path): Option[(String, Path)] =
|
||||||
|
path.toList match {
|
||||||
|
case head :: tail => Some(head -> Path(tail))
|
||||||
|
case Nil => None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Root extractor:
|
||||||
|
* {{{
|
||||||
|
* Path("/") match {
|
||||||
|
* case Root => ...
|
||||||
|
* }
|
||||||
|
* }}}
|
||||||
|
*/
|
||||||
|
case object Root extends Path {
|
||||||
|
// override def /(child: String): Path = new Blank(this, child)
|
||||||
|
def toList: List[String] = Nil
|
||||||
|
def parent: Path = this
|
||||||
|
def lastOption: None.type = None
|
||||||
|
override def toString = "Root"
|
||||||
|
def pathString: String = "#"
|
||||||
|
def startsWith(other: Path): Boolean = other == Root
|
||||||
|
}
|
||||||
|
|
||||||
|
private[router] object UrlCodingUtils {
|
||||||
|
|
||||||
|
private val lower = ('a' to 'z').toSet
|
||||||
|
private val upper = ('A' to 'Z').toSet
|
||||||
|
private val num = ('0' to '9').toSet
|
||||||
|
val Unreserved: Set[Char] = lower ++ upper ++ num ++ "-_.~"
|
||||||
|
|
||||||
|
private val toSkip: Set[Char] = Unreserved ++ "!$&'()*+,;=:/?@"
|
||||||
|
|
||||||
|
private val HexUpperCaseChars: Array[Char] = ('A' to 'F').toArray
|
||||||
|
|
||||||
|
/** Percent-encodes a string. Depending on the parameters, this method is
|
||||||
|
* appropriate for URI or URL form encoding. Any resulting percent-encodings
|
||||||
|
* are normalized to uppercase.
|
||||||
|
*
|
||||||
|
* @param toEncode the string to encode
|
||||||
|
* @param charset the charset to use for characters that are percent encoded
|
||||||
|
* @param spaceIsPlus if space is not skipped, determines whether it will be
|
||||||
|
* rendreed as a `"+"` or a percent-encoding according to `charset`.
|
||||||
|
* @param toSkip a predicate of characters exempt from encoding. In typical
|
||||||
|
* use, this is composed of all Unreserved URI characters and sometimes a
|
||||||
|
* subset of Reserved URI characters.
|
||||||
|
*/
|
||||||
|
def urlEncode(
|
||||||
|
toEncode: String,
|
||||||
|
charset: Charset = UTF_8,
|
||||||
|
spaceIsPlus: Boolean = false,
|
||||||
|
toSkip: Char => Boolean = toSkip
|
||||||
|
): String = {
|
||||||
|
val in = charset.encode(toEncode)
|
||||||
|
val out = CharBuffer.allocate((in.remaining() * 3).toInt)
|
||||||
|
while (in.hasRemaining) {
|
||||||
|
val c = in.get().toChar
|
||||||
|
if (toSkip(c)) {
|
||||||
|
out.put(c)
|
||||||
|
} else if (c == ' ' && spaceIsPlus) {
|
||||||
|
out.put('+')
|
||||||
|
} else {
|
||||||
|
out.put('%')
|
||||||
|
out.put(HexUpperCaseChars((c >> 4) & 0xf))
|
||||||
|
out.put(HexUpperCaseChars(c & 0xf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.flip()
|
||||||
|
out.toString
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SkipEncodeInPath =
|
||||||
|
Unreserved ++ ":@!$&'()*+,;="
|
||||||
|
|
||||||
|
def pathEncode(s: String, charset: Charset = UTF_8): String =
|
||||||
|
UrlCodingUtils.urlEncode(s, charset, false, SkipEncodeInPath)
|
||||||
|
|
||||||
|
/** Percent-decodes a string.
|
||||||
|
*
|
||||||
|
* @param toDecode the string to decode
|
||||||
|
* @param charset the charset of percent-encoded characters
|
||||||
|
* @param plusIsSpace true if `'+'` is to be interpreted as a `' '`
|
||||||
|
* @param toSkip a predicate of characters whose percent-encoded form
|
||||||
|
* is left percent-encoded. Almost certainly should be left empty.
|
||||||
|
*/
|
||||||
|
def urlDecode(
|
||||||
|
toDecode: String,
|
||||||
|
charset: Charset = UTF_8,
|
||||||
|
plusIsSpace: Boolean = false,
|
||||||
|
toSkip: Char => Boolean = Function.const(false)
|
||||||
|
): String = {
|
||||||
|
val in = CharBuffer.wrap(toDecode)
|
||||||
|
// reserve enough space for 3-byte UTF-8 characters. 4-byte characters are represented
|
||||||
|
// as surrogate pairs of characters, and will get a luxurious 6 bytes of space.
|
||||||
|
val out = ByteBuffer.allocate(in.remaining() * 3)
|
||||||
|
while (in.hasRemaining) {
|
||||||
|
val mark = in.position()
|
||||||
|
val c = in.get()
|
||||||
|
if (c == '%') {
|
||||||
|
if (in.remaining() >= 2) {
|
||||||
|
val xc = in.get()
|
||||||
|
val yc = in.get()
|
||||||
|
// scalastyle:off magic.number
|
||||||
|
val x = Character.digit(xc, 0x10)
|
||||||
|
val y = Character.digit(yc, 0x10)
|
||||||
|
// scalastyle:on magic.number
|
||||||
|
if (x != -1 && y != -1) {
|
||||||
|
val oo = (x << 4) + y
|
||||||
|
if (!toSkip(oo.toChar)) {
|
||||||
|
out.put(oo.toByte)
|
||||||
|
} else {
|
||||||
|
out.put('%'.toByte)
|
||||||
|
out.put(xc.toByte)
|
||||||
|
out.put(yc.toByte)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.put('%'.toByte)
|
||||||
|
in.position(mark + 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// This is an invalid encoding. Fail gracefully by treating the '%' as
|
||||||
|
// a literal.
|
||||||
|
out.put(c.toByte)
|
||||||
|
while (in.hasRemaining) out.put(in.get().toByte)
|
||||||
|
}
|
||||||
|
} else if (c == '+' && plusIsSpace) {
|
||||||
|
out.put(' '.toByte)
|
||||||
|
} else {
|
||||||
|
// normally `out.put(c.toByte)` would be enough since the url is %-encoded,
|
||||||
|
// however there are cases where a string can be partially decoded
|
||||||
|
// so we have to make sure the non us-ascii chars get preserved properly.
|
||||||
|
if (this.toSkip(c)) {
|
||||||
|
out.put(c.toByte)
|
||||||
|
} else {
|
||||||
|
out.put(charset.encode(String.valueOf(c)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.flip()
|
||||||
|
charset.decode(out).toString
|
||||||
|
}
|
||||||
|
}
|
5
src/main/scala/outwatch/router/dsl/package.scala
Normal file
5
src/main/scala/outwatch/router/dsl/package.scala
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
package outwatch.router
|
||||||
|
|
||||||
|
// import outwatch.router.dsl.Extractors
|
||||||
|
|
||||||
|
package object dsl extends Extractors
|
10
src/main/scala/outwatch/router/package.scala
Normal file
10
src/main/scala/outwatch/router/package.scala
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package outwatch
|
||||||
|
|
||||||
|
import outwatch._
|
||||||
|
import colibri.ProSubject
|
||||||
|
|
||||||
|
package object router {
|
||||||
|
type RouterStore[P] = ProSubject[Action, (Action, RouterState[P])]
|
||||||
|
|
||||||
|
type RouterResolve[P] = PartialFunction[P, VDomModifier]
|
||||||
|
}
|
48
src/main/scala/outwatch/util/MonixWebSocket.scala
Normal file
48
src/main/scala/outwatch/util/MonixWebSocket.scala
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package outwatch.util
|
||||||
|
|
||||||
|
import outwatch.helpers._
|
||||||
|
// import colibri._
|
||||||
|
import org.scalajs.dom.{Event, MessageEvent}
|
||||||
|
import cats.effect.Sync
|
||||||
|
import monix.reactive.Observable
|
||||||
|
import monix.reactive.Observer
|
||||||
|
import monix.execution.Cancelable
|
||||||
|
import monix.reactive.OverflowStrategy
|
||||||
|
import scala.concurrent.Future
|
||||||
|
import monix.execution.Ack
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
object MonixWS {
|
||||||
|
// implicit def toObserver[F[_]: Sync, S: Show](
|
||||||
|
// socket: MonixWS[F, S]
|
||||||
|
// ): F[Observer[S]] = socket.sink
|
||||||
|
// implicit def toObservable[F[_]: Sync, S: Show](
|
||||||
|
// socket: MonixWS[F, S]
|
||||||
|
// ): Observable[MessageEvent] = socket.source
|
||||||
|
}
|
||||||
|
|
||||||
|
class MonixWS[F[_], S](val url: String)(implicit F: Sync[F], S: Show[S]) {
|
||||||
|
val ws = new org.scalajs.dom.WebSocket(url)
|
||||||
|
|
||||||
|
lazy val source: Observable[MessageEvent] =
|
||||||
|
Observable.create[MessageEvent](OverflowStrategy.Unbounded) { sub =>
|
||||||
|
ws.onmessage = (e: MessageEvent) => sub.onNext(e)
|
||||||
|
ws.onerror =
|
||||||
|
(e: Event) => sub.onError(new Exception(s"Error in WebSocket: $e"))
|
||||||
|
Cancelable(() => ws.close())
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy val sink: F[Observer[S]] = {
|
||||||
|
F.delay {
|
||||||
|
new Observer[S] {
|
||||||
|
override def onNext(elem: S): Future[Ack] = {
|
||||||
|
ws.send(S.show(elem))
|
||||||
|
Future.successful(Ack.Continue)
|
||||||
|
}
|
||||||
|
override def onError(ex: Throwable): Unit =
|
||||||
|
OutwatchTracing.errorSubject.onNext(ex)
|
||||||
|
override def onComplete(): Unit = ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
39
src/main/scala/outwatch/util/WebSocket2.scala
Normal file
39
src/main/scala/outwatch/util/WebSocket2.scala
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package outwatch.util
|
||||||
|
|
||||||
|
import outwatch.helpers._
|
||||||
|
import colibri._
|
||||||
|
import org.scalajs.dom.{Event, MessageEvent}
|
||||||
|
import cats.effect.Sync
|
||||||
|
|
||||||
|
object WebSocketF {
|
||||||
|
implicit def toObserver[F[_]: Sync](
|
||||||
|
socket: WebSocketF[F]
|
||||||
|
): F[Observer[String]] =
|
||||||
|
socket.sink
|
||||||
|
implicit def toObservable[F[_]: Sync](
|
||||||
|
socket: WebSocketF[F]
|
||||||
|
): Observable[MessageEvent] =
|
||||||
|
socket.source
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketF[F[_]](val url: String)(implicit F: Sync[F]) {
|
||||||
|
val ws = new org.scalajs.dom.WebSocket(url)
|
||||||
|
|
||||||
|
lazy val source: Observable[MessageEvent] =
|
||||||
|
Observable.create[MessageEvent] { observer =>
|
||||||
|
ws.onmessage = (e: MessageEvent) => observer.onNext(e)
|
||||||
|
ws.onerror =
|
||||||
|
(e: Event) => observer.onError(new Exception(s"Error in WebSocket: $e"))
|
||||||
|
Cancelable(() => ws.close())
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy val sink: F[Observer[String]] = {
|
||||||
|
F.delay {
|
||||||
|
new Observer[String] {
|
||||||
|
override def onNext(elem: String): Unit = ws.send(elem)
|
||||||
|
override def onError(ex: Throwable): Unit =
|
||||||
|
OutwatchTracing.errorSubject.onNext(ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
src/main/scala/outwatchapp/IOUtils.scala
Normal file
19
src/main/scala/outwatchapp/IOUtils.scala
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import cats.arrow.FunctionK
|
||||||
|
import monix.bio.IO
|
||||||
|
|
||||||
|
object IOUtils {
|
||||||
|
def toIO[T](task: monix.eval.Task[T]) =
|
||||||
|
IO.deferAction(implicit s => IO.from(task))
|
||||||
|
|
||||||
|
def toTask[T](bio: monix.bio.IO[Throwable, T]) =
|
||||||
|
monix.eval.Task.deferAction(implicit s => bio.to[monix.eval.Task])
|
||||||
|
|
||||||
|
val ioTaskMapk =
|
||||||
|
new FunctionK[monix.eval.Task, monix.bio.Task] {
|
||||||
|
|
||||||
|
override def apply[A](
|
||||||
|
fa: monix.eval.Task[A]
|
||||||
|
): monix.bio.Task[A] = toIO(fa)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
291
src/main/scala/outwatchapp/OutwatchApp.scala
Normal file
291
src/main/scala/outwatchapp/OutwatchApp.scala
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
package outwatchapp
|
||||||
|
|
||||||
|
import outwatch._
|
||||||
|
import outwatch.dsl._
|
||||||
|
|
||||||
|
import cats.effect.ExitCode
|
||||||
|
import monix.bio._
|
||||||
|
import colibri.ext.monix._
|
||||||
|
import monix.reactive.Observable
|
||||||
|
import scala.concurrent.duration._
|
||||||
|
import cats.implicits._
|
||||||
|
import outwatch.router.AppRouter
|
||||||
|
import outwatch.router.dsl._
|
||||||
|
import sttp.client.impl.monix.FetchMonixBackend
|
||||||
|
import outwatch.reactive.handlers.monix._
|
||||||
|
import sttp.client._
|
||||||
|
import monix.{eval => me}
|
||||||
|
import nova.monadic_sfx.ui.components.todo.TodoListStore
|
||||||
|
import outwatchapp.implicits._
|
||||||
|
import monix.eval.Coeval
|
||||||
|
import nova.monadic_sfx.ui.components.todo.Todo
|
||||||
|
|
||||||
|
object OutwatchApp extends BIOApp {
|
||||||
|
val counter2 = Observable.interval(1.second)
|
||||||
|
val counter = div(
|
||||||
|
"Hmm",
|
||||||
|
button(
|
||||||
|
onClick
|
||||||
|
.useScan(0)(_ + 1)
|
||||||
|
.handled(source => VDomModifier("Counter: ", source))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
implicit val backend = FetchMonixBackend()
|
||||||
|
|
||||||
|
val handlerTask = Handler.createF[Task, String]("empty")
|
||||||
|
|
||||||
|
def request(query: String) = basicRequest
|
||||||
|
.get(
|
||||||
|
uri"https://jsonplaceholder.typicode.com/todos/${query.toIntOption.getOrElse(0)}"
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.flatMap {
|
||||||
|
_.body match {
|
||||||
|
case Right(value) =>
|
||||||
|
me.Task(println(value)) >>
|
||||||
|
me.Task(value)
|
||||||
|
case Left(error) =>
|
||||||
|
me.Task(println(error)) >>
|
||||||
|
me.Task(error)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val component = Task.deferAction(implicit s =>
|
||||||
|
for {
|
||||||
|
handler <- handlerTask
|
||||||
|
todoContent <- Handler.createF[Task, String]
|
||||||
|
todoStore <- TodoListStore()
|
||||||
|
res <- Task(
|
||||||
|
div(
|
||||||
|
// cls := "col-lg-8 bd-example",
|
||||||
|
div(cls := "alert alert-danger", "Some Error Occured!"),
|
||||||
|
div(
|
||||||
|
form(
|
||||||
|
h4(cls := "form-title", "User Finder"),
|
||||||
|
div(
|
||||||
|
cls := "form-group",
|
||||||
|
label(
|
||||||
|
color := "hsla(0,0%,100%,0.8)",
|
||||||
|
forId := "httpInput",
|
||||||
|
"Enter an id: "
|
||||||
|
),
|
||||||
|
input(
|
||||||
|
idAttr := "httpInput",
|
||||||
|
typ := "text",
|
||||||
|
cls := "form-control",
|
||||||
|
placeholder := "0",
|
||||||
|
onInput.value --> handler
|
||||||
|
),
|
||||||
|
label(
|
||||||
|
color := "hsla(0,0%,100%,0.8)",
|
||||||
|
"Enter content for todo"
|
||||||
|
),
|
||||||
|
small(cls := "form-text text-muted", "default is 0")
|
||||||
|
),
|
||||||
|
div(
|
||||||
|
cls := "form-group",
|
||||||
|
input(
|
||||||
|
cls := "form-control",
|
||||||
|
onInput.value --> todoContent
|
||||||
|
),
|
||||||
|
button(
|
||||||
|
cls := "btn",
|
||||||
|
cls := "btn-info form-control",
|
||||||
|
"Add Todo",
|
||||||
|
onClick.preventDefault(
|
||||||
|
todoContent.map(TodoListStore.Add)
|
||||||
|
) --> todoStore.sink
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
div(
|
||||||
|
p(
|
||||||
|
cls := "profile-description",
|
||||||
|
handler
|
||||||
|
.doOnNext(str => me.Task(println(str)))
|
||||||
|
.mapEval(request)
|
||||||
|
.map(div(_)),
|
||||||
|
div(
|
||||||
|
"Todos: ",
|
||||||
|
todoStore.doOnNextF { case (a, s) => Coeval(println(s)) }.map {
|
||||||
|
case (a, s) => div(renderTodos(s.todos))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} yield res
|
||||||
|
)
|
||||||
|
|
||||||
|
def renderTodos(todos: Seq[Todo]) = div(
|
||||||
|
ul(
|
||||||
|
todos.map(todo => li(div(s"id: ${todo.id} content: ${todo.content}")))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed trait Page
|
||||||
|
case object Home extends Page
|
||||||
|
case object SomePage extends Page
|
||||||
|
case class UserHome(id: Int) extends Page
|
||||||
|
case object NotFound extends Page
|
||||||
|
|
||||||
|
val router = AppRouter.create[Task, Page](NotFound) {
|
||||||
|
case Root => Home
|
||||||
|
case Root / "user" / IntVar(id) => UserHome(id)
|
||||||
|
case Root / "some-page" => SomePage
|
||||||
|
}
|
||||||
|
val counterComponent =
|
||||||
|
Task.deferAction(implicit s =>
|
||||||
|
Task(div(p(cls := "profile-description", "count: ", counter2)))
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolver: PartialFunction[Page, VDomModifier] = {
|
||||||
|
case Home =>
|
||||||
|
div(
|
||||||
|
div(cls := "title", "Home"),
|
||||||
|
div(
|
||||||
|
cls := "card",
|
||||||
|
div(
|
||||||
|
cls := "card-body",
|
||||||
|
counterComponent,
|
||||||
|
p(
|
||||||
|
cls := "profile-description",
|
||||||
|
div(
|
||||||
|
"hm",
|
||||||
|
htmlTag("blockQuote")(
|
||||||
|
cls := "blockquote",
|
||||||
|
p(
|
||||||
|
cls := "mb-0",
|
||||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a ante."
|
||||||
|
),
|
||||||
|
footer(
|
||||||
|
cls := "blockquote-footer",
|
||||||
|
"Someone famous in ",
|
||||||
|
cite(title := "Source Title", "Source Title")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
case SomePage =>
|
||||||
|
div(div(cls := "title", "SomePage"), component, div(cls := "slider"))
|
||||||
|
case UserHome(id) => div(div(cls := "title", "UserHome"), s"User id: $id")
|
||||||
|
case NotFound =>
|
||||||
|
div(
|
||||||
|
div(cls := "title", "NotFound"),
|
||||||
|
p(cls := "profile-description", "notfound")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def run(args: List[String]): UIO[ExitCode] = {
|
||||||
|
// div(h1("Hello World"), counterComponent, Task(1))
|
||||||
|
import org.scalajs.dom.document
|
||||||
|
val el =
|
||||||
|
document.createElement("div")
|
||||||
|
el.setAttribute("id", "#app")
|
||||||
|
document.body.appendChild(el)
|
||||||
|
|
||||||
|
router.store
|
||||||
|
.flatMap(implicit store =>
|
||||||
|
OutWatch
|
||||||
|
.renderInto(
|
||||||
|
el,
|
||||||
|
div(
|
||||||
|
htmlTag("nav")(
|
||||||
|
cls := "navbar navbar-expand-lg bg-primary ",
|
||||||
|
attr("color-on-scroll") := "100",
|
||||||
|
div(
|
||||||
|
cls := "container",
|
||||||
|
div(
|
||||||
|
cls := "navbar-translate",
|
||||||
|
a(
|
||||||
|
cls := "navbar-brand",
|
||||||
|
href := "https://demos.creative-tim.com/blk-design-system/index.html",
|
||||||
|
rel := "tooltip",
|
||||||
|
title := "",
|
||||||
|
attr("data-placement") := "bottom",
|
||||||
|
target := "_blank",
|
||||||
|
div("OutwatchApp")
|
||||||
|
),
|
||||||
|
button(
|
||||||
|
cls := "navbar-toggler navbar-toggler toggled collapsed",
|
||||||
|
attr("data-toggle") := "collapse",
|
||||||
|
attr("data-target") := "#navigation",
|
||||||
|
attr("aria-controls") := "navigation-index",
|
||||||
|
attr("aria-expanded") := "false",
|
||||||
|
attr("aria-label") := "Toggle navigation",
|
||||||
|
div(cls := "navbar-toggler-bar bar1"),
|
||||||
|
div(cls := "navbar-toggler-bar bar2"),
|
||||||
|
div(cls := "navbar-toggler-bar bar3")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
div(
|
||||||
|
cls := "navbar-collapse justify-content-end collapse",
|
||||||
|
idAttr := "navigation",
|
||||||
|
div(
|
||||||
|
cls := "navbar-collapse-header",
|
||||||
|
div(
|
||||||
|
cls := "row",
|
||||||
|
div(
|
||||||
|
cls := "col-6 collapse-brand",
|
||||||
|
a("BLK•")
|
||||||
|
),
|
||||||
|
div(
|
||||||
|
cls := "col-6 collapse-close text-right",
|
||||||
|
button(
|
||||||
|
`type` := "button",
|
||||||
|
cls := "navbar-toggler collapsed",
|
||||||
|
attr("data-toggle") := "collapse",
|
||||||
|
attr("data-target") := "#navigation",
|
||||||
|
attr("aria-controls") := "navigation-index",
|
||||||
|
attr("aria-expanded") := "false",
|
||||||
|
attr("aria-label") := "Toggle navigation",
|
||||||
|
i(cls := "tim-icons icon-simple-remove")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
ul(
|
||||||
|
cls := "navbar-nav",
|
||||||
|
li(
|
||||||
|
cls := "nav-item active",
|
||||||
|
router.link("/")(
|
||||||
|
cls := "nav-link",
|
||||||
|
"Home",
|
||||||
|
div(cls := "sr-only", "(current)")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
li(
|
||||||
|
cls := "nav-item",
|
||||||
|
router
|
||||||
|
.link("/some-page")(cls := "nav-link", "SomePage")
|
||||||
|
),
|
||||||
|
li(
|
||||||
|
cls := "nav-item",
|
||||||
|
router.link("/user/1")(cls := "nav-link", "User Home")
|
||||||
|
),
|
||||||
|
li(
|
||||||
|
cls := "nav-item",
|
||||||
|
router.link("/todomvc")(cls := "nav-link", "TodoMvc")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
div(
|
||||||
|
cls := "container",
|
||||||
|
router.render(resolver),
|
||||||
|
router.watch()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.onErrorHandle(ex => UIO(ex.printStackTrace()))
|
||||||
|
.as(ExitCode.Success)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,83 @@
|
|||||||
|
package nova.monadic_sfx.ui.components.todo
|
||||||
|
|
||||||
|
import cats.kernel.Eq
|
||||||
|
import com.softwaremill.quicklens._
|
||||||
|
import io.circe.generic.JsonCodec
|
||||||
|
import monix.bio.Task
|
||||||
|
import nova.monadic_sfx.util.reactive.store.Reducer
|
||||||
|
import nova.monadic_sfx.util.reactive.store.Store
|
||||||
|
|
||||||
|
case class Todo(id: Int, content: String)
|
||||||
|
object Todo {
|
||||||
|
implicit val eqForTodo = Eq.fromUniversalEquals[Todo]
|
||||||
|
}
|
||||||
|
|
||||||
|
object TodoListStore {
|
||||||
|
|
||||||
|
@JsonCodec
|
||||||
|
sealed trait Action
|
||||||
|
case object Init extends Action
|
||||||
|
case class Add(content: String) extends Action
|
||||||
|
case class Edit(id: Int, content: String) extends Action
|
||||||
|
case class Delete(id: Int) extends Action
|
||||||
|
|
||||||
|
private case class InternalAdd(content: String) extends Action
|
||||||
|
private case object End extends Action
|
||||||
|
object Action {
|
||||||
|
implicit val eqForAction = Eq.fromUniversalEquals[Action]
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
case class State(todos: Vector[Todo], counter: Int)
|
||||||
|
object State {
|
||||||
|
implicit val eqForState = Eq.fromUniversalEquals[State]
|
||||||
|
}
|
||||||
|
|
||||||
|
def reducer()(
|
||||||
|
state: State,
|
||||||
|
action: Action
|
||||||
|
): (State, Option[Task[Action]]) =
|
||||||
|
action match {
|
||||||
|
case Init => (state, None)
|
||||||
|
case Add(content) =>
|
||||||
|
println("hello")
|
||||||
|
val nextAction = Some(for {
|
||||||
|
// do some validation
|
||||||
|
// _ <- logger.debug(s"Received $content")
|
||||||
|
_ <- Task(println(s"Received $content"))
|
||||||
|
res <- Task.pure(InternalAdd(content))
|
||||||
|
} yield res)
|
||||||
|
(state, nextAction)
|
||||||
|
case Edit(id, content) =>
|
||||||
|
val condition: Todo => Boolean = _.id == id
|
||||||
|
val nextState = state
|
||||||
|
.modify(_.todos.eachWhere(condition))
|
||||||
|
.using(_.copy(content = content))
|
||||||
|
(nextState, None)
|
||||||
|
case Delete(id) =>
|
||||||
|
(state.copy(state.todos.filterNot(_.id == id)), None)
|
||||||
|
|
||||||
|
case InternalAdd(content) =>
|
||||||
|
val nextState =
|
||||||
|
state
|
||||||
|
.modify(_.todos)
|
||||||
|
.using(_ :+ Todo(state.counter, content))
|
||||||
|
.modify(_.counter)
|
||||||
|
.using(_ + 1)
|
||||||
|
(nextState, Some(Task.pure(End)))
|
||||||
|
case End => (state, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
def apply(): Task[Store[Action, State]] =
|
||||||
|
Task.deferAction(implicit s =>
|
||||||
|
for {
|
||||||
|
store <-
|
||||||
|
Store
|
||||||
|
.createL[Action, State](
|
||||||
|
Init,
|
||||||
|
State(Vector.empty[Todo], 0),
|
||||||
|
Reducer.withOptionalEffects(reducer() _)
|
||||||
|
)
|
||||||
|
} yield store
|
||||||
|
)
|
||||||
|
}
|
35
src/main/scala/outwatchapp/implicits/package.scala
Normal file
35
src/main/scala/outwatchapp/implicits/package.scala
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package outwatchapp
|
||||||
|
|
||||||
|
import colibri.Sink
|
||||||
|
import nova.monadic_sfx.util.reactive.store.MonixProSubject
|
||||||
|
import cats.effect.Sync
|
||||||
|
import colibri.CreateProSubject
|
||||||
|
import colibri.Source
|
||||||
|
import colibri.LiftSink
|
||||||
|
import monix.reactive.Observer
|
||||||
|
import colibri.LiftSource
|
||||||
|
import monix.reactive.Observable
|
||||||
|
import colibri.ext.monix._
|
||||||
|
import monix.execution.Scheduler
|
||||||
|
import monix.execution.Ack
|
||||||
|
|
||||||
|
package object implicits {
|
||||||
|
// implicit def sinkForMyMonixProSub[A, M] =
|
||||||
|
// new Sink[MonixProSubject[A, M]] {}\
|
||||||
|
// implicit val monixCreateProSubject = new CreateProSubject[MonixProSubject] {
|
||||||
|
// @inline def from[SI[_]: Sink, SO[_]: Source, I, O](
|
||||||
|
// sink: SI[I],
|
||||||
|
// source: SO[O]
|
||||||
|
// ): MonixProSubject[I, O] = MonixProSubject.from(
|
||||||
|
// LiftSink[Observer].lift(sink),
|
||||||
|
// LiftSource[Observable].lift(source)
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
implicit class Ops[A](val sink: Observer[A]) {
|
||||||
|
@inline def liftSink[G[_]: LiftSink]: G[A] = LiftSink[G].lift(sink)
|
||||||
|
}
|
||||||
|
implicit class Ops2[A](val source: Observable[A])(implicit s: Scheduler) {
|
||||||
|
@inline def liftSource[G[_]: LiftSource]: G[A] = LiftSource[G].lift(source)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
package nova.monadic_sfx.util.reactive.store
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
import io.circe.Encoder
|
||||||
|
import io.circe.Printer
|
||||||
|
import io.circe.generic.JsonCodec
|
||||||
|
import io.circe.syntax._
|
||||||
|
import monix.bio.Task
|
||||||
|
import monix.reactive.Observable
|
||||||
|
// object Middleware {
|
||||||
|
// def apply[A,M,T](ob: Observable[(A,M)], cb: (A,M) => T): Observable[(A,M)] = ob
|
||||||
|
// }
|
||||||
|
|
||||||
|
@JsonCodec
|
||||||
|
final case class StoreInfo[A](
|
||||||
|
name: String,
|
||||||
|
action: A,
|
||||||
|
time: LocalDateTime
|
||||||
|
)
|
||||||
|
|
||||||
|
object StoreInfo {
|
||||||
|
val printer = Printer.noSpaces
|
||||||
|
}
|
||||||
|
|
||||||
|
object Middlewares {}
|
@ -0,0 +1,23 @@
|
|||||||
|
package nova.monadic_sfx.util.reactive.store
|
||||||
|
|
||||||
|
import scala.concurrent.Future
|
||||||
|
|
||||||
|
import monix.execution.Ack
|
||||||
|
import monix.execution.Cancelable
|
||||||
|
import monix.reactive.Observable
|
||||||
|
import monix.reactive.Observer
|
||||||
|
import monix.reactive.observers.Subscriber
|
||||||
|
|
||||||
|
object MonixProSubject {
|
||||||
|
def from[I, O](
|
||||||
|
observer: Observer[I],
|
||||||
|
observable: Observable[O]
|
||||||
|
): MonixProSubject[I, O] =
|
||||||
|
new Observable[O] with Observer[I] {
|
||||||
|
override def onNext(elem: I): Future[Ack] = observer.onNext(elem)
|
||||||
|
override def onError(ex: Throwable): Unit = observer.onError(ex)
|
||||||
|
override def onComplete(): Unit = observer.onComplete()
|
||||||
|
override def unsafeSubscribeFn(subscriber: Subscriber[O]): Cancelable =
|
||||||
|
observable.unsafeSubscribeFn(subscriber)
|
||||||
|
}
|
||||||
|
}
|
39
src/main/scala/outwatchapp/util/reactive/store/Reducer.scala
Normal file
39
src/main/scala/outwatchapp/util/reactive/store/Reducer.scala
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package nova.monadic_sfx.util.reactive.store
|
||||||
|
|
||||||
|
import cats.implicits._
|
||||||
|
import monix.reactive.Observable
|
||||||
|
import monix.reactive.ObservableLike
|
||||||
|
|
||||||
|
object Reducer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Reducer which yields a new State, as-well as an Observable of Effects
|
||||||
|
* Effects are Actions which will be executed after the Action that caused them to occur.
|
||||||
|
* This is accomplished by subscribing to the Effects Observable within the stores scan loop.
|
||||||
|
*
|
||||||
|
* CAUTION: There is currently a bug which causes the Effect-States to emit,
|
||||||
|
* before the State of the action that caused the effects is emitted.
|
||||||
|
* However, this only effects immediate emissions of the Effects Observable, delayed emissions should be fine.
|
||||||
|
* @param f The Reducing Function returning the (Model, Effects) tuple.
|
||||||
|
*/
|
||||||
|
def withEffects[F[_]: ObservableLike, A, M](
|
||||||
|
f: (M, A) => (M, F[A])
|
||||||
|
): Reducer[A, M] = (s: M, a: A) => f(s, a).map(ObservableLike[F].apply)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reducer which just transforms the state, without additional effects.
|
||||||
|
*/
|
||||||
|
def apply[A, M](f: (M, A) => M): Reducer[A, M] =
|
||||||
|
(s: M, a: A) => f(s, a) -> Observable.empty
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Reducer with an optional effect.
|
||||||
|
*/
|
||||||
|
def withOptionalEffects[F[_]: ObservableLike, A, M](
|
||||||
|
f: (M, A) => (M, Option[F[A]])
|
||||||
|
): Reducer[A, M] =
|
||||||
|
(s: M, a: A) =>
|
||||||
|
f(s, a).map(
|
||||||
|
_.fold[Observable[A]](Observable.empty)(ObservableLike[F].apply)
|
||||||
|
)
|
||||||
|
}
|
54
src/main/scala/outwatchapp/util/reactive/store/Store.scala
Normal file
54
src/main/scala/outwatchapp/util/reactive/store/Store.scala
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package nova.monadic_sfx.util.reactive.store
|
||||||
|
|
||||||
|
import monix.bio.Task
|
||||||
|
import monix.eval.Coeval
|
||||||
|
import monix.reactive.OverflowStrategy
|
||||||
|
import monix.reactive.subjects.ConcurrentSubject
|
||||||
|
|
||||||
|
object Store {
|
||||||
|
def createL[A, M](
|
||||||
|
initialAction: A,
|
||||||
|
initialState: M,
|
||||||
|
reducer: Reducer[A, M],
|
||||||
|
middlewares: Seq[Middleware[A, M]] = Seq.empty,
|
||||||
|
overflowStrategy: OverflowStrategy.Synchronous[A] =
|
||||||
|
OverflowStrategy.DropOld(50)
|
||||||
|
): Task[Store[A, M]] =
|
||||||
|
Task.deferAction { implicit s =>
|
||||||
|
Task {
|
||||||
|
val subject = ConcurrentSubject.publish[A](overflowStrategy)
|
||||||
|
|
||||||
|
val fold: ((A, M), A) => Coeval[(A, M)] = {
|
||||||
|
case ((_, state), action) =>
|
||||||
|
Coeval {
|
||||||
|
val (newState, effects) = reducer(state, action)
|
||||||
|
|
||||||
|
effects.subscribe(subject.onNext _)
|
||||||
|
|
||||||
|
action -> newState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val obs = subject
|
||||||
|
.scanEval0F[Coeval, (A, M)](
|
||||||
|
Coeval.pure(initialAction -> initialState)
|
||||||
|
)(fold)
|
||||||
|
|
||||||
|
val res = middlewares
|
||||||
|
.foldLeft(obs) {
|
||||||
|
case (obs, middleware) => middleware(obs)
|
||||||
|
}
|
||||||
|
.doOnNextF(i => Coeval(println(s"Emitted item 1: $i")))
|
||||||
|
.behavior(initialAction -> initialState)
|
||||||
|
.refCount
|
||||||
|
|
||||||
|
// .doOnNextF(i => Coeval(println(s"Emitted item 2: $i")))
|
||||||
|
|
||||||
|
MonixProSubject.from(
|
||||||
|
subject,
|
||||||
|
res
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
18
src/main/scala/outwatchapp/util/reactive/store/package.scala
Normal file
18
src/main/scala/outwatchapp/util/reactive/store/package.scala
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package nova.monadic_sfx.util.reactive
|
||||||
|
|
||||||
|
import monix.reactive.Observable
|
||||||
|
import monix.reactive.Observer
|
||||||
|
|
||||||
|
package object store {
|
||||||
|
type MonixProSubject[-I, +O] = Observable[O] with Observer[I]
|
||||||
|
type Middleware[A, M] = Observable[(A, M)] => Observable[(A, M)]
|
||||||
|
type Store[A, M] = MonixProSubject[A, (A, M)]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Function that applies an Action onto the Stores current state.
|
||||||
|
* @param reducer The reducing function
|
||||||
|
* @tparam A The Action Type
|
||||||
|
* @tparam M The Model Type
|
||||||
|
*/
|
||||||
|
type Reducer[A, M] = (M, A) => (M, Observable[A])
|
||||||
|
}
|
23
src/test/scala/outwatchapp/JSDomSpec.scala
Normal file
23
src/test/scala/outwatchapp/JSDomSpec.scala
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package outwatchapp
|
||||||
|
|
||||||
|
import org.scalajs.dom._
|
||||||
|
import org.scalatest.BeforeAndAfterEach
|
||||||
|
import org.scalatest.flatspec.AnyFlatSpec
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
abstract class JSDomSpec
|
||||||
|
extends AnyFlatSpec
|
||||||
|
with Matchers
|
||||||
|
with BeforeAndAfterEach {
|
||||||
|
|
||||||
|
override def beforeEach(): Unit = {
|
||||||
|
|
||||||
|
document.body.innerHTML = ""
|
||||||
|
|
||||||
|
// prepare body with <div id="app"></div>
|
||||||
|
val root = document.createElement("div")
|
||||||
|
root.id = "app"
|
||||||
|
document.body.appendChild(root)
|
||||||
|
()
|
||||||
|
}
|
||||||
|
}
|
18
src/test/scala/outwatchapp/OutwatchtestSpec.scala
Normal file
18
src/test/scala/outwatchapp/OutwatchtestSpec.scala
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package outwatchapp
|
||||||
|
|
||||||
|
import org.scalajs.dom._
|
||||||
|
import outwatch._
|
||||||
|
import outwatch.dsl._
|
||||||
|
|
||||||
|
import cats.effect.IO
|
||||||
|
|
||||||
|
class OutwatchtestSpec extends JSDomSpec {
|
||||||
|
|
||||||
|
"You" should "probably add some tests" in {
|
||||||
|
|
||||||
|
val message = "Hello World!"
|
||||||
|
OutWatch.renderInto[IO]("#app", h1(message)).unsafeRunSync()
|
||||||
|
|
||||||
|
document.body.innerHTML.contains(message) shouldBe true
|
||||||
|
}
|
||||||
|
}
|
25
webpack.config.dev.js
Normal file
25
webpack.config.dev.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
var webpack = require('webpack');
|
||||||
|
|
||||||
|
module.exports = require('./scalajs.webpack.config');
|
||||||
|
|
||||||
|
const Path = require('path');
|
||||||
|
const rootDir = Path.resolve(__dirname, '../../../..');
|
||||||
|
// module.exports.module = {
|
||||||
|
// ...module.exports.module,
|
||||||
|
// rules: module.exports.module.rules.concat([
|
||||||
|
// {
|
||||||
|
// test: /\.css$/i,
|
||||||
|
// use: ['style-loader', 'css-loader'],
|
||||||
|
// },
|
||||||
|
// ]),
|
||||||
|
// };
|
||||||
|
module.exports.devServer = {
|
||||||
|
contentBase: [
|
||||||
|
Path.resolve(__dirname, 'dev'), // fastOptJS output
|
||||||
|
Path.resolve(rootDir, 'assets') // project root containing index.html
|
||||||
|
],
|
||||||
|
watchContentBase: true,
|
||||||
|
hot: false,
|
||||||
|
hotOnly: false, // only reload when build is successful
|
||||||
|
inline: true // live reloading
|
||||||
|
};
|
13
webpack.config.js
Normal file
13
webpack.config.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
var webpack = require('webpack');
|
||||||
|
|
||||||
|
module.exports = require('./scalajs.webpack.config');
|
||||||
|
|
||||||
|
module.exports.module = {
|
||||||
|
...module.exports.module,
|
||||||
|
rules: module.exports.module.rules.concat([
|
||||||
|
{
|
||||||
|
test: /\.css$/i,
|
||||||
|
use: ['style-loader', 'css-loader'],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user