├── .gitignore ├── .scalafix.conf ├── .scalafmt.conf ├── Procfile ├── README.md ├── application.conf ├── backend └── src │ ├── main │ └── scala │ │ └── zio │ │ └── slides │ │ ├── Config.scala │ │ ├── SlideApp.scala │ │ └── SlideAppServer.scala │ └── test │ └── scala │ └── zio │ └── slides │ └── SlideAppSpec.scala ├── build.sbt ├── deploy.sh ├── frontend └── src │ ├── main │ ├── scala │ │ ├── components │ │ │ └── package.scala │ │ ├── io │ │ │ └── laminext │ │ │ │ └── websocket │ │ │ │ └── zio.scala │ │ └── zio │ │ │ └── slides │ │ │ ├── Admin.scala │ │ │ ├── AnimatedCount.scala │ │ │ ├── Component.scala │ │ │ ├── Config.scala │ │ │ ├── Main.scala │ │ │ ├── Slide.scala │ │ │ ├── Slides.scala │ │ │ ├── Step.scala │ │ │ ├── Styles.scala │ │ │ └── VoteModule.scala │ └── static │ │ └── stylesheets │ │ └── main.scss │ └── test │ └── scala │ └── zio │ └── slides │ └── VoteStateSpec.scala ├── index.html ├── package.json ├── project ├── build.properties └── plugins.sbt ├── shared └── src │ └── main │ └── scala │ └── zio │ └── slides │ ├── ClientCommand.scala │ ├── Question.scala │ ├── QuestionState.scala │ ├── ServerCommand.scala │ ├── SlideState.scala │ └── VoteState.scala ├── vite.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .bsp/ 2 | .idea/ 3 | node_modules/ 4 | target/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [ 2 | RemoveUnused, 3 | LeakingImplicitClassVal 4 | ] -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 2.7.5 2 | maxColumn = 120 3 | align.preset = more -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: backend/target/universal/stage/bin/backend 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zio-slides 2 | 3 | An interactive, websocket-backed slide presentation app running on `ZIO`, `zio-http`, and `Laminar`. 4 | 5 | ## Running Locally 6 | 7 | 1. `sbt ~frontend/fastLinkJS` in another tab. 8 | 2. `sbt ~backend/reStart` in another tab. 9 | 3. `yarn install` 10 | 4. `yarn exec vite` 11 | 5. open `http://localhost:3000` 12 | -------------------------------------------------------------------------------- /application.conf: -------------------------------------------------------------------------------- 1 | adminPassword=hunter2 -------------------------------------------------------------------------------- /backend/src/main/scala/zio/slides/Config.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import zio._ 4 | import zio.config._ 5 | import zio.config.magnolia._ 6 | 7 | case class Config(adminPassword: String) 8 | 9 | object Config { 10 | val descriptor: _root_.zio.config.ConfigDescriptor[Config] = Descriptor[Config].desc 11 | 12 | val live: ZLayer[Any, Nothing, Config] = 13 | ZConfig.fromPropertiesFile("../application.conf", descriptor).orDie 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/main/scala/zio/slides/SlideApp.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import zio._ 4 | import zio.slides.VoteState.{CastVoteId, UserId} 5 | import zio.stream._ 6 | 7 | /** Improvements: 8 | * 9 | * - Add a VoteStateRef. Send the current Vote State to a user 10 | * when they join. 11 | * 12 | * - When a user disconnects, remove their votes. 13 | */ 14 | trait SlideApp { 15 | def slideStateStream: UStream[SlideState] 16 | def questionStateStream: UStream[QuestionState] 17 | def voteStream: UStream[Vector[CastVoteId]] 18 | def populationStatsStream: UStream[PopulationStats] 19 | 20 | def receiveUserCommand(id: UserId, userCommand: UserCommand): UIO[Unit] 21 | def receiveAdminCommand(adminCommand: AdminCommand): UIO[Unit] 22 | 23 | def userJoined: UIO[Unit] 24 | def userLeft: UIO[Unit] 25 | } 26 | 27 | object SlideApp { 28 | val live: ZLayer[Any, Nothing, SlideApp] = SlideAppLive.layer 29 | 30 | // Accessor Methods 31 | 32 | def slideStateStream: ZStream[SlideApp, Nothing, SlideState] = 33 | ZStream.environmentWithStream[SlideApp](_.get.slideStateStream) 34 | 35 | def questionStateStream: ZStream[SlideApp, Nothing, QuestionState] = 36 | ZStream.environmentWithStream[SlideApp](_.get.questionStateStream) 37 | 38 | def voteStream: ZStream[SlideApp, Nothing, Vector[CastVoteId]] = 39 | ZStream.environmentWithStream[SlideApp](_.get.voteStream) 40 | 41 | def populationStatsStream: ZStream[SlideApp, Nothing, PopulationStats] = 42 | ZStream.environmentWithStream[SlideApp](_.get.populationStatsStream) 43 | 44 | def receiveUserCommand(id: UserId, userCommand: UserCommand): ZIO[SlideApp, Nothing, Unit] = 45 | ZIO.environmentWithZIO[SlideApp](_.get.receiveUserCommand(id, userCommand)) 46 | 47 | def receiveAdminCommand(adminCommand: AdminCommand): ZIO[SlideApp, Nothing, Unit] = 48 | ZIO.environmentWithZIO[SlideApp](_.get.receiveAdminCommand(adminCommand)) 49 | 50 | def userJoined: ZIO[SlideApp, Nothing, Unit] = 51 | ZIO.environmentWithZIO[SlideApp](_.get.userJoined) 52 | 53 | def userLeft: ZIO[SlideApp, Nothing, Unit] = 54 | ZIO.environmentWithZIO[SlideApp](_.get.userLeft) 55 | } 56 | 57 | case class SlideAppLive( 58 | slideStateRef: Ref[SlideState], 59 | slideStateStream: UStream[SlideState], 60 | questionStateRef: Ref[QuestionState], 61 | questionStateStream: UStream[QuestionState], 62 | voteQueue: Queue[CastVoteId], 63 | voteStream: UStream[Vector[CastVoteId]], 64 | populationStatsRef: Ref[PopulationStats], 65 | populationStatsStream: UStream[PopulationStats] 66 | ) extends SlideApp { 67 | 68 | def receiveAdminCommand(adminCommand: AdminCommand): UIO[Unit] = 69 | adminCommand match { 70 | case AdminCommand.NextSlide => slideStateRef.update(_.nextSlide) 71 | case AdminCommand.PrevSlide => slideStateRef.update(_.prevSlide) 72 | case AdminCommand.NextStep => slideStateRef.update(_.nextStep) 73 | case AdminCommand.PrevStep => slideStateRef.update(_.prevStep) 74 | case AdminCommand.ToggleQuestion(id) => 75 | questionStateRef.update(_.toggleQuestion(id)) 76 | } 77 | 78 | def receiveUserCommand(id: UserId, userCommand: UserCommand): UIO[Unit] = 79 | userCommand match { 80 | case UserCommand.AskQuestion(question, slideIndex) => 81 | questionStateRef.update(_.askQuestion(question, slideIndex)) 82 | case UserCommand.SendVote(topic, vote) => 83 | voteQueue.offer(CastVoteId(id, topic, vote)).unit 84 | case UserCommand.Subscribe => 85 | UIO.unit 86 | } 87 | 88 | override def userLeft: UIO[Unit] = { 89 | println("USER LEFT CALLED!") 90 | populationStatsRef.update(_.removeOne) 91 | } 92 | 93 | override def userJoined: UIO[Unit] = 94 | populationStatsRef.update(_.addOne) 95 | } 96 | 97 | object SlideAppLive { 98 | val layer: ZLayer[Any, Nothing, SlideApp] = ZLayer.scoped { 99 | for { 100 | slideVar <- SubscriptionRef.make(SlideState.empty) 101 | questionsVar <- SubscriptionRef.make(QuestionState.empty) 102 | populationStatsVar <- SubscriptionRef.make(PopulationStats(0)) 103 | 104 | voteQueue <- Queue.bounded[CastVoteId](256) 105 | voteStream <- ZStream.fromQueue(voteQueue).groupedWithin(100, 300.millis).broadcastDynamic(128) 106 | } yield SlideAppLive( 107 | slideStateRef = slideVar, 108 | slideStateStream = slideVar.changes, 109 | questionStateRef = questionsVar, 110 | questionStateStream = questionsVar.changes, 111 | voteQueue = voteQueue, 112 | voteStream = voteStream.map(_.toVector), 113 | populationStatsRef = populationStatsVar, 114 | populationStatsStream = populationStatsVar.changes 115 | ) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /backend/src/main/scala/zio/slides/SlideAppServer.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import boopickle.Default._ 4 | import zhttp.http._ 5 | import zhttp.service._ 6 | import zhttp.socket._ 7 | import zio._ 8 | import zio.slides.ServerCommand._ 9 | import zio.slides.VoteState.UserId 10 | import zio.stream.ZStream 11 | 12 | import java.nio.ByteBuffer 13 | import scala.util.{Failure, Success, Try} 14 | 15 | object SlideAppServer extends ZIOAppDefault { 16 | 17 | final case class Routes(slideApp: SlideApp, config: Config) { 18 | def adminSocket: SocketApp[Any] = { 19 | pickleSocket { (command: AdminCommand) => 20 | ZStream.fromZIO(slideApp.receiveAdminCommand(command)).drain 21 | } 22 | } 23 | 24 | def userSocket: SocketApp[Any] = { 25 | val userId = UserId.random 26 | 27 | pickleSocket { (command: UserCommand) => 28 | command match { 29 | case UserCommand.Subscribe => 30 | ZStream 31 | .mergeAllUnbounded()( 32 | ZStream.fromZIO(slideApp.userJoined).drain, 33 | slideApp.slideStateStream.map(SendSlideState), 34 | slideApp.questionStateStream.map(SendQuestionState), 35 | slideApp.voteStream.map(SendVotes), 36 | slideApp.populationStatsStream.map(SendPopulationStats), 37 | ZStream.succeed[ServerCommand](SendUserId(userId)) 38 | ) 39 | .map { s => 40 | val bytes: ByteBuffer = Pickle.intoBytes(s) 41 | WebSocketFrame.binary(Chunk.fromArray(bytes.array())) 42 | } 43 | 44 | case command => 45 | ZStream.fromZIO(slideApp.receiveUserCommand(userId, command)).drain 46 | } 47 | }.onClose { conn => 48 | // TODO: Fix zio-http onClose method not being called 49 | Runtime.default.unsafeRunAsync( 50 | ZIO.debug("Closing connection") *> 51 | slideApp.userLeft 52 | ) 53 | ZIO.debug(s"ON CLOSE CALLED WITH CONN $conn") 54 | } 55 | 56 | } 57 | 58 | val app: HttpApp[Any, Throwable] = 59 | Http.collectZIO[Request] { 60 | case Method.GET -> !! / "ws" => 61 | Response.fromSocketApp(userSocket) 62 | 63 | case req @ Method.GET -> !! / "ws" / "admin" => 64 | req.url.queryParams.getOrElse("password", List.empty) match { 65 | case List(password) if password == config.adminPassword => 66 | Response.fromSocketApp(adminSocket) 67 | case _ => 68 | ZIO.succeed(Response.fromHttpError(HttpError.Unauthorized("INVALID PASSWORD"))) 69 | } 70 | } 71 | } 72 | 73 | object Routes { 74 | val live = ZLayer.fromFunction(Routes.apply _) 75 | } 76 | 77 | override val run = (for { 78 | port <- System.envOrElse("PORT", "8088").map(_.toInt).orElseSucceed(8088) 79 | _ <- SlideApp.populationStatsStream 80 | .foreach(stats => Console.printLine(s"CONNECTED USERS ${stats.connectedUsers}")) 81 | .fork 82 | _ <- Console.printLine(s"STARTING SERVER ON PORT $port") 83 | app <- ZIO.serviceWith[Routes](_.app) 84 | _ <- Server.start(port, app) 85 | } yield ()) 86 | .debug("SERVER COMPLETE") 87 | .provide(SlideApp.live, Config.live, Routes.live) 88 | 89 | private def pickleSocket[R, E <: Throwable, A: Pickler](f: A => ZStream[R, E, WebSocketFrame]): SocketApp[R] = 90 | SocketApp( 91 | Socket.collect[WebSocketFrame] { 92 | case WebSocketFrame.Binary(bytes) => 93 | Try(Unpickle[A].fromBytes(ByteBuffer.wrap(bytes.toArray))) match { 94 | case Failure(error) => 95 | ZStream.fromZIO(Console.printError(s"Decoding Error: $error").!).drain 96 | case Success(command) => 97 | f(command) 98 | } 99 | case other => 100 | ZStream.fromZIO(ZIO.succeed(println(s"RECEIVED $other"))).drain 101 | } 102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /backend/src/test/scala/zio/slides/SlideAppSpec.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import zio._ 4 | import zio.test._ 5 | 6 | object SlideAppSpec extends ZIOSpecDefault { 7 | def simulateUser: ZIO[SlideApp with Live, Nothing, TestResult] = Live.live(for { 8 | _ <- Console.printLine("STARTING").orDie 9 | delay <- Random.nextIntBetween(0, 3) 10 | amountToTake <- Random.nextIntBetween(2, 5000).delay(delay.seconds) 11 | receivedSlideIndices <- SlideApp.slideStateStream.take(amountToTake).runCollect.map(_.map(_.slideIndex)) 12 | expected = Chunk.fromIterable(receivedSlideIndices.min to receivedSlideIndices.max) 13 | } yield assertTrue(receivedSlideIndices == expected)) 14 | 15 | def spec = 16 | suite("SlideAppSpec")( 17 | test("subscriptions are interruptible") { 18 | val total = 100 19 | for { 20 | _ <- SlideApp.receiveAdminCommand(AdminCommand.NextSlide).forever.fork 21 | _ <- Live.live(ZIO.sleep(1.seconds)) 22 | ref <- Ref.make(0) 23 | reportFinish = ref.getAndUpdate(_ + 1).flatMap(i => Console.printLine(s"FINISHED ${i + 1} / $total")) 24 | all <- ZIO.collectAllPar(List.fill(total)(simulateUser <* reportFinish)) 25 | } yield BoolAlgebra.collectAll(all).getOrElse(assertCompletes.negate) 26 | } 27 | ).provideCustomLayer(SlideApp.live) 28 | } 29 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := "zio-slides" 2 | 3 | version := "0.1" 4 | 5 | val animusVersion = "0.1.12" 6 | val laminarVersion = "0.14.2" 7 | val zioConfigVersion = "3.0.0-RC8" 8 | val zioHttpVersion = "2.0.0-RC7" 9 | val zioVersion = "2.0.0-RC5" 10 | 11 | Global / onChangedBuildSource := ReloadOnSourceChanges 12 | 13 | inThisBuild( 14 | List( 15 | scalaVersion := "2.13.8", 16 | semanticdbEnabled := true, 17 | semanticdbVersion := scalafixSemanticdb.revision 18 | ) 19 | ) 20 | 21 | val sharedSettings = Seq( 22 | addCompilerPlugin("org.typelevel" %% "kind-projector" % "0.13.2" cross CrossVersion.full), 23 | addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1"), 24 | scalacOptions ++= Seq("-Ymacro-annotations", "-Xfatal-warnings"), 25 | resolvers ++= Seq( 26 | "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots", 27 | "Sonatype OSS Snapshots s01" at "https://s01.oss.sonatype.org/content/repositories/snapshots" 28 | ), 29 | libraryDependencies ++= Seq( 30 | "io.suzaku" %%% "boopickle" % "1.3.2", 31 | "dev.zio" %%% "zio" % zioVersion, 32 | "dev.zio" %%% "zio-streams" % zioVersion, 33 | "org.scala-lang" % "scala-reflect" % scalaVersion.value % Provided 34 | ), 35 | scalaVersion := "2.13.8" 36 | ) 37 | 38 | scalacOptions ++= Seq("-Ymacro-annotations", "-Xfatal-warnings") 39 | 40 | lazy val backend = project 41 | .in(file("backend")) 42 | .enablePlugins(JavaAppPackaging) 43 | .settings( 44 | sharedSettings, 45 | testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework"), 46 | libraryDependencies ++= Seq( 47 | "dev.zio" %% "zio-config" % zioConfigVersion, 48 | "dev.zio" %% "zio-config-magnolia" % zioConfigVersion, 49 | "dev.zio" %% "zio-test" % zioVersion % Test, 50 | "dev.zio" %% "zio-test-sbt" % zioVersion % Test, 51 | "io.d11" %% "zhttp" % zioHttpVersion 52 | ) 53 | ) 54 | .dependsOn(shared) 55 | 56 | lazy val frontend = project 57 | .in(file("frontend")) 58 | .enablePlugins(ScalaJSPlugin) 59 | .settings( 60 | scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) }, 61 | scalaJSLinkerConfig ~= { _.withSourceMap(false) }, 62 | scalaJSUseMainModuleInitializer := true, 63 | libraryDependencies ++= Seq( 64 | "io.github.kitlangton" %%% "animus" % animusVersion, 65 | "com.raquo" %%% "laminar" % laminarVersion, 66 | "io.github.cquiroz" %%% "scala-java-time" % "2.3.0", 67 | "io.laminext" %%% "websocket" % "0.14.3" 68 | ) 69 | ) 70 | .settings(sharedSettings) 71 | .dependsOn(shared) 72 | 73 | lazy val shared = project 74 | .enablePlugins(ScalaJSPlugin) 75 | .in(file("shared")) 76 | .settings( 77 | sharedSettings, 78 | scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) }, 79 | scalaJSLinkerConfig ~= { _.withSourceMap(false) } 80 | ) 81 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | sbt frontend/fullLinkJS 2 | yarn exec vite -- build 3 | cp dist/index.html dist/200.html 4 | surge ./dist 'zio-slides.surge.sh' -------------------------------------------------------------------------------- /frontend/src/main/scala/components/package.scala: -------------------------------------------------------------------------------- 1 | import com.raquo.laminar.api.L._ 2 | import animus._ 3 | 4 | package object components { 5 | def FadeInWords(string: String, $active0: Signal[Boolean]): Modifier[HtmlElement] = { 6 | string.split(" ").zipWithIndex.toList.map { case (word, idx) => 7 | val $active = 8 | $active0.flatMap { 9 | case true => 10 | EventStream.fromValue(true).delay(idx * 100).startWith(false) 11 | case false => Val(false) 12 | } 13 | 14 | div( 15 | word + nbsp, 16 | lineHeight("1.5"), 17 | display.inlineFlex, 18 | Transitions.opacity($active), 19 | position.relative, 20 | Transitions.height($active), 21 | onMountBind { el => 22 | top <-- $active.map { if (_) 0.0 else el.thisNode.ref.scrollHeight.toDouble }.spring.px 23 | } 24 | ) 25 | } 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/main/scala/io/laminext/websocket/zio.scala: -------------------------------------------------------------------------------- 1 | package io.laminext.websocket 2 | import _root_.boopickle.Default._ 3 | import org.scalajs.dom 4 | import java.nio.ByteBuffer 5 | import scala.scalajs.js.typedarray.{ArrayBuffer, TypedArrayBuffer} 6 | 7 | object boopickle { 8 | import scala.scalajs.js.typedarray.TypedArrayBufferOps._ 9 | 10 | implicit class WebSocketReceiveBuilderBooPickleOps(b: WebSocketReceiveBuilder) { 11 | @inline def pickle[Receive, Send](implicit 12 | receiveDecoder: Pickler[Receive], 13 | sendEncoder: Pickler[Send] 14 | ): WebSocketBuilder[Receive, Send] = 15 | new WebSocketBuilder[Receive, Send]( 16 | url = b.url, 17 | initializer = initialize.arraybuffer, 18 | sender = { (webSocket: dom.WebSocket, a: Send) => 19 | val bytes: ByteBuffer = Pickle.intoBytes(a) 20 | val buffer = bytes.arrayBuffer() 21 | send.arraybuffer.apply(webSocket, buffer) 22 | }, 23 | receiver = { msg => 24 | Right(Unpickle[Receive].fromBytes(TypedArrayBuffer.wrap(msg.data.asInstanceOf[ArrayBuffer]))) 25 | }, 26 | protocol = "ws" 27 | ) 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/main/scala/zio/slides/Admin.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import boopickle.Default._ 4 | import com.raquo.laminar.api.L._ 5 | import io.laminext.websocket.WebSocket 6 | import io.laminext.websocket.boopickle._ 7 | import org.scalajs.dom.window 8 | import zio.slides.State.{questionStateVar, slideStateVar} 9 | import zio.slides.Styles.panelStyles 10 | 11 | object Admin { 12 | lazy val localStoragePassword = Option(window.localStorage.getItem("password")) 13 | 14 | val adminWs: WebSocket[ServerCommand, AdminCommand] = 15 | WebSocket 16 | .url(Config.webSocketsUrl + s"/admin?password=${localStoragePassword.getOrElse("")}") 17 | .pickle[ServerCommand, AdminCommand] 18 | .build(reconnectRetries = 0) 19 | 20 | def AdminPanel: Div = 21 | div( 22 | localStoragePassword.map { _ => 23 | adminWs.connect 24 | }, 25 | child.maybe <-- adminWs.isConnected.map { 26 | Option.when(_) { 27 | div( 28 | display <-- adminWs.isConnected.map(if (_) "block" else "none"), 29 | AllQuestions, 30 | windowEvents.onKeyDown.map(_.key) --> { 31 | case "ArrowRight" => 32 | slideStateVar.update(_.nextSlide) 33 | adminWs.sendOne(AdminCommand.NextSlide) 34 | case "ArrowLeft" => 35 | slideStateVar.update(_.prevSlide) 36 | adminWs.sendOne(AdminCommand.PrevSlide) 37 | case "ArrowDown" => 38 | slideStateVar.update(_.nextStep) 39 | adminWs.sendOne(AdminCommand.NextStep) 40 | case "ArrowUp" => 41 | slideStateVar.update(_.prevStep) 42 | adminWs.sendOne(AdminCommand.PrevStep) 43 | case _ => () 44 | } 45 | ) 46 | } 47 | } 48 | ) 49 | 50 | private def AllQuestions: Div = 51 | div( 52 | panelStyles(Val(true)), 53 | cls("all-questions"), 54 | children <-- questionStateVar.signal.map(_.questions).split(_.id) { (id, question, _) => 55 | div( 56 | color <-- questionStateVar.signal.map(qs => if (qs.activeQuestionId.contains(id)) "green" else "white"), 57 | textAlign.left, 58 | fontSize.medium, 59 | cursor.pointer, 60 | margin("8px 0px"), 61 | question.question, 62 | nbsp, 63 | span(question.slideIndex.show, opacity(0.5), fontStyle.italic, fontSize.medium), 64 | onClick --> { _ => adminWs.sendOne(AdminCommand.ToggleQuestion(id)) } 65 | ) 66 | } 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /frontend/src/main/scala/zio/slides/AnimatedCount.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import com.raquo.laminar.api.L._ 4 | import animus._ 5 | 6 | case class AnimatedCount($count: Signal[Int]) extends Component { 7 | val $digits: Signal[List[(String, Int)]] = $count.map(_.toString.split("").reverse.zipWithIndex.reverse.toList) 8 | 9 | override def body: HtmlElement = 10 | div( 11 | textAlign.center, 12 | fontFamily("Source Code Pro"), 13 | display.flex, 14 | justifyContent.center, 15 | children <-- $digits.splitTransition(_._2) { case (_, _, signal, t0) => 16 | div( 17 | position.relative, 18 | children <-- signal 19 | .map(_._1) 20 | .splitOneTransition(identity) { (_, int, _, t1) => 21 | div( 22 | int, 23 | transform <-- t1.$isActive.map { if (_) 1.0 else 0.0 }.spring.map { s => s"scaleY($s)" }, 24 | transformOrigin("top"), 25 | t1.opacity, 26 | t1.height, 27 | t0.width 28 | ) 29 | } 30 | .map(_.reverse) 31 | ) 32 | } 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/main/scala/zio/slides/Component.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import com.raquo.laminar.api.L._ 4 | 5 | import scala.language.implicitConversions 6 | 7 | trait Component { 8 | def body: HtmlElement 9 | } 10 | 11 | object Component { 12 | implicit def toLaminarElement(component: Component): HtmlElement = component.body 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/main/scala/zio/slides/Config.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import org.scalajs.dom.window 4 | 5 | object Config { 6 | val isLocalHost: Boolean = window.location.host.startsWith("localhost") 7 | 8 | val webSocketsUrl: String = 9 | if (isLocalHost) "ws://localhost:8088/ws" 10 | else "wss://young-brushlands-01236.herokuapp.com/ws" 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/main/scala/zio/slides/Main.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import com.raquo.laminar.api.L._ 4 | import org.scalajs.dom 5 | 6 | import scala.scalajs.js 7 | import scala.scalajs.js.annotation.JSImport 8 | 9 | @js.native 10 | @JSImport("stylesheets/main.scss", JSImport.Namespace) 11 | object Css extends js.Any 12 | 13 | object Main { 14 | val css: Css.type = Css 15 | 16 | def main(args: Array[String]): Unit = { 17 | val _ = documentEvents.onDomContentLoaded.foreach { _ => 18 | val appContainer = dom.document.querySelector("#app") 19 | appContainer.innerHTML = "" 20 | val _ = render(appContainer, Slides.view) 21 | }(unsafeWindowOwner) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/main/scala/zio/slides/Slide.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import animus._ 4 | import com.raquo.laminar.api.L._ 5 | import zio.slides.VoteModule.VotesView 6 | import components.FadeInWords 7 | import zio.slides.VoteState.Topic 8 | 9 | import scala.util.Random 10 | 11 | trait Slide { 12 | def render($step: Signal[Int]): HtmlElement 13 | } 14 | 15 | object Components { 16 | def title(modifiers: Modifier[HtmlElement]*) = 17 | h1(modifiers) 18 | 19 | def slideIn($isActive: Signal[Boolean])(component: => Modifier[HtmlElement]): Modifier[HtmlElement] = 20 | children <-- $isActive.splitOneTransition(identity) { (_, b, _, transition) => 21 | if (b) { 22 | div( 23 | div(component), 24 | Transitions.heightDynamic(transition.$isActive) 25 | ) 26 | } else { 27 | div() 28 | } 29 | } 30 | } 31 | 32 | object Slide { 33 | 34 | object Slide_1 extends Slide { 35 | override def render($step: Signal[Int]): HtmlElement = 36 | div( 37 | Components.title( 38 | justifyContent.center, 39 | // display.flex, 40 | children <-- $step.map(_ > 1) 41 | .map { if (_) "Redeeming" else "Building" } 42 | .splitOneTransition(identity) { (_, word, _, transition) => 43 | div( 44 | display.inlineFlex, 45 | overflow.hidden, 46 | div(word), 47 | Transitions.widthDynamic(transition.$isActive) 48 | // transition.width 49 | ) 50 | }, 51 | s"${nbsp}a ZIO App" 52 | ), 53 | FadeInWords("🤞 🤞 🤞 🤞 🤞 🤞 🤞 🤞 🤞 🤞 🤞", $step.map(_ > 2)), 54 | p( 55 | FadeInWords("Today, we're going to build an interactive slide app with ZIO.", $step.map(_ > 0)) 56 | ), 57 | FadeInWords("🤞 🤞 🤞 🤞 🤞 🤞 🤞 🤞 🤞 🤞 🤞", $step.map(_ > 2)), 58 | p( 59 | FadeInWords("And it's actually going to work.", $step.map(_ > 1)) 60 | ), 61 | FadeInWords("🤞 🤞 🤞 🤞 🤞 🤞 🤞 🤞 🤞 🤞 🤞", $step.map(_ > 2)) 62 | ) 63 | } 64 | 65 | object Slide_2 extends Slide { 66 | override def render($step: Signal[Int]): HtmlElement = { 67 | 68 | def slideSim($active: Signal[Boolean], $step: Signal[Int]): Div = div( 69 | width("120px"), 70 | height("90px"), 71 | background("#444"), 72 | borderRadius("4px"), 73 | padding("12px"), 74 | opacity <-- $active.map { if (_) 1.0 else 0.5 }.spring, 75 | transform <-- $active.map { if (_) 1.0 else 0.95 }.spring.map { s => s"scale($s)" }, 76 | Components.slideIn($step.map(_ >= 0)) { 77 | div( 78 | height(s"${Random.nextInt(15) + 10}px"), 79 | background("#888"), 80 | borderRadius("4px"), 81 | marginBottom("8px") 82 | ) 83 | }, 84 | Components.slideIn($step.map(_ >= 1)) { 85 | div( 86 | height(s"${Random.nextInt(20) + 10}px"), 87 | background("#888"), 88 | borderRadius("4px"), 89 | marginBottom("8px") 90 | ) 91 | }, 92 | Components.slideIn($step.map(_ >= 2)) { 93 | div( 94 | height(s"${Random.nextInt(20) + 10}px"), 95 | background("#888"), 96 | marginBottom("8px"), 97 | borderRadius("4px") 98 | ) 99 | } 100 | ) 101 | 102 | div( 103 | Components.title("Meta"), 104 | p( 105 | FadeInWords("This is a slideshow.", $step.map(_ > 0)) 106 | ), 107 | p( 108 | FadeInWords("A Slide Index is being broadcast via WebSockets.", $step.map(_ > 1)) 109 | ), 110 | div( 111 | marginBottom("24px"), 112 | display.flex, 113 | justifyContent.center, 114 | alignItems.center, 115 | div( 116 | div( 117 | fontSize.small, 118 | opacity(0.8), 119 | "SLIDE" 120 | ), 121 | AnimatedCount($step.map(_ / 3 + 1)) 122 | ), 123 | div(width("24px")), 124 | div( 125 | div( 126 | fontSize.small, 127 | opacity(0.8), 128 | "STEP" 129 | ), 130 | AnimatedCount($step.map(_ % 3 + 1)) 131 | ) 132 | ), 133 | div( 134 | display.flex, 135 | justifyContent.center, 136 | alignItems.center, 137 | slideSim($step.map(Set(0, 1, 2)), $step), 138 | div(width("24px")), 139 | slideSim($step.map(Set(3, 4, 5)), $step.map(_ - 3)), 140 | div(width("24px")), 141 | slideSim($step.map(Set(6, 7, 8)), $step.map(_ - 6)) 142 | ), 143 | p( 144 | FadeInWords("Everything is written in Scala.", $step.map(_ > 3)) 145 | ), 146 | p( 147 | FadeInWords("I love Scala 😭", $step.map(_ > 5)) 148 | ) 149 | ) 150 | } 151 | 152 | } 153 | 154 | object Slide_3 extends Slide { 155 | override def render($step: Signal[Int]): HtmlElement = 156 | div( 157 | h1("A Special Guest!"), 158 | h3( 159 | FadeInWords("Tushar Mathur", $step.map(_ > 0)) 160 | ) 161 | ) 162 | } 163 | 164 | object Slide_4 extends Slide { 165 | override def render($step: Signal[Int]): HtmlElement = 166 | div( 167 | h1("POLL / DDOS"), 168 | p( 169 | FadeInWords("What's your preferred Scala web server?", $step.map(_ > 0)) 170 | ), 171 | Components.slideIn($step.map(_ > 1)) { 172 | VotesView( 173 | Topic("Web Frameworks"), 174 | List( 175 | "Akka HTTP", 176 | "Cask", 177 | "Finch", 178 | "Play", 179 | "http4s", 180 | "zio-http" 181 | ), 182 | $step.map(_ <= 2) 183 | ) 184 | } 185 | ) 186 | } 187 | 188 | object Slide_5 extends Slide { 189 | override def render($step: Signal[Int]): HtmlElement = 190 | div( 191 | h1("Today's Topics"), 192 | p("Hover over a topic to vote!"), 193 | VotesView() 194 | ) 195 | } 196 | 197 | val exampleSlides: Vector[Slide] = Vector(Slide_1, Slide_2, Slide_3, Slide_4) 198 | } 199 | -------------------------------------------------------------------------------- /frontend/src/main/scala/zio/slides/Slides.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import _root_.boopickle.Default._ 4 | import _root_.zio.slides.State._ 5 | import _root_.zio.slides.Styles._ 6 | import animus._ 7 | import com.raquo.laminar.api.L._ 8 | import io.laminext.websocket._ 9 | import io.laminext.websocket.boopickle.WebSocketReceiveBuilderBooPickleOps 10 | 11 | import scala.concurrent.duration.DurationInt 12 | 13 | object State { 14 | val slideStateVar: Var[SlideState] = Var(SlideState.empty) 15 | val questionStateVar: Var[QuestionState] = Var(QuestionState.empty) 16 | val voteStateVar = Var(VoteState.empty) 17 | val userIdVar: Var[Option[VoteState.UserId]] = Var(Option.empty[VoteState.UserId]) 18 | } 19 | 20 | object Slides { 21 | val ws: WebSocket[ServerCommand, UserCommand] = 22 | WebSocket 23 | .url(Config.webSocketsUrl) 24 | .pickle[ServerCommand, UserCommand] 25 | .build(reconnectRetries = Int.MaxValue, reconnectDelay = 3.seconds) 26 | 27 | val slideIndexOverride: Var[Option[SlideIndex]] = Var(None) 28 | val isAskingVar = Var(Option.empty[SlideIndex]) 29 | val populationStatsVar: Var[PopulationStats] = Var(PopulationStats(1)) 30 | 31 | def BottomPanel: Div = 32 | div( 33 | position.fixed, 34 | zIndex(2), 35 | bottom("0"), 36 | left("0"), 37 | right("0"), 38 | windowEvents.onKeyDown.filter(k => k.key == "q" && k.ctrlKey) --> { _ => 39 | val state = slideStateVar.now() 40 | isAskingVar.update { 41 | case Some(_) => None 42 | case None => Some(SlideIndex(state.slideIndex, state.stepIndex)) 43 | } 44 | }, 45 | questionStateVar.signal --> { state => 46 | val question = state.questions.find(q => state.activeQuestionId.contains(q.id)) 47 | question match { 48 | case Some(question) => slideIndexOverride.set(Some(question.slideIndex)) 49 | case None => slideIndexOverride.set(None) 50 | } 51 | }, 52 | ActiveQuestion, 53 | AskQuestion, 54 | Admin.AdminPanel 55 | ) 56 | 57 | private def AskQuestion = { 58 | val questionVar = Var("") 59 | 60 | def submitQuestion(): Unit = 61 | isAskingVar.now().foreach { index => 62 | val question = questionVar.now() 63 | if (question.nonEmpty) { 64 | ws.sendOne(UserCommand.AskQuestion(question, index)) 65 | isAskingVar.set(None) 66 | questionVar.set("") 67 | } 68 | } 69 | 70 | div( 71 | panelStyles(isAskingVar.signal.map(_.isDefined)), 72 | textAlign.left, 73 | div( 74 | display.flex, 75 | fontStyle.italic, 76 | fontSize.medium, 77 | opacity(0.7), 78 | "ASK A QUESTION", 79 | paddingBottom("12px"), 80 | div(flex("1")), 81 | child.text <-- questionVar.signal.map { string => 82 | s"${string.length} / 240" 83 | } 84 | ), 85 | textArea( 86 | display.block, 87 | height("100px"), 88 | disabled <-- isAskingVar.signal.map(_.isEmpty), 89 | inContext { el => 90 | isAskingVar.signal.changes.filter(_.isDefined) --> { _ => 91 | el.ref.focus() 92 | } 93 | }, 94 | onKeyDown.filter(k => k.key == "Enter" && k.metaKey).map(_.key) --> { _ => submitQuestion() }, 95 | onKeyDown.map(_.key).filter(_ == "Escape") --> { _ => isAskingVar.set(None) }, 96 | controlled( 97 | value <-- questionVar, 98 | onInput.mapToValue.map(_.take(240)) --> questionVar 99 | ) 100 | ), 101 | div( 102 | display.flex, 103 | button("CANCEL", cls("cancel"), onClick --> { _ => isAskingVar.set(None) }), 104 | div(width("24px")), 105 | button("SEND", onClick --> { _ => submitQuestion() }) 106 | ) 107 | ) 108 | } 109 | 110 | private def ActiveQuestion = 111 | div( 112 | panelStyles(questionStateVar.signal.map(_.activeQuestion.isDefined)), 113 | textAlign.left, 114 | div( 115 | div( 116 | fontStyle.italic, 117 | fontSize.medium, 118 | opacity(0.7), 119 | "QUESTION" 120 | ), 121 | div( 122 | padding("12px 0"), 123 | fontWeight.bold, 124 | child.maybe <-- questionStateVar.signal.map(_.activeQuestion.map(_.question)) 125 | ) 126 | ) 127 | ) 128 | 129 | lazy val $connectionStatus = ws.isConnected 130 | .combineWithFn(ws.isConnecting) { 131 | case (true, _) => "CONNECTED" 132 | case (_, true) => "CONNECTING" 133 | case _ => "OFFLINE" 134 | } 135 | 136 | def view: Div = { 137 | div( 138 | ws.connect, 139 | ws.connected --> { _ => 140 | ws.sendOne(UserCommand.Subscribe) 141 | }, 142 | VoteModule.voteBus.events.debounce(200) --> { vote => 143 | println(s"SENDING VOTE $vote ") 144 | ws.sendOne(vote) 145 | }, 146 | ws.received --> { command => 147 | println(s"RECEIVED COMMAND: $command") 148 | command match { 149 | case ServerCommand.SendSlideState(slideState) => 150 | slideStateVar.set(slideState) 151 | case ServerCommand.SendQuestionState(questionState) => 152 | questionStateVar.set(questionState) 153 | case ServerCommand.SendVotes(votes) => 154 | voteStateVar.update(_.processUpdates(votes.filterNot(v => userIdVar.now().contains(v.id)))) 155 | case ServerCommand.SendUserId(id) => 156 | userIdVar.set(Some(id)) 157 | case ServerCommand.SendPopulationStats(populationStats) => 158 | populationStatsVar.set(populationStats) 159 | } 160 | }, 161 | BottomPanel, 162 | textAlign.center, 163 | div( 164 | position.relative, 165 | position.fixed, 166 | top("0"), 167 | bottom("0"), 168 | left("0"), 169 | right("0"), 170 | background("#111"), 171 | cls <-- isAskingVar.signal 172 | .map(_.isDefined) 173 | .combineWithFn(questionStateVar.signal.map(_.activeQuestion.isDefined))(_ || _) 174 | .map { 175 | if (_) "slide-app-shrink" else "slide-app" 176 | }, 177 | div( 178 | margin("20px"), 179 | display.flex, 180 | flexDirection.column, 181 | height("40px"), 182 | // justifyContent.spaceBetween, 183 | alignItems.flexEnd, 184 | div( 185 | fontSize("16px"), 186 | lineHeight("1.5"), 187 | div( 188 | "ZYMPOSIUM" 189 | ), 190 | onDblClick --> { _ => 191 | val state = slideStateVar.now() 192 | isAskingVar.update { 193 | case Some(_) => None 194 | case None => Some(SlideIndex(state.slideIndex, state.stepIndex)) 195 | } 196 | } 197 | ), 198 | div( 199 | fontSize("14px"), 200 | opacity(0.7), 201 | div( 202 | lineHeight("1.5"), 203 | display.flex, 204 | children <-- $connectionStatus.splitOneTransition(identity) { (_, string, _, transition) => 205 | div(string, transition.width, transition.opacity) 206 | }, 207 | overflowY.hidden, 208 | height <-- EventStream 209 | .merge( 210 | $connectionStatus.changes.debounce(5000).mapTo(false), 211 | $connectionStatus.changes.mapTo(true) 212 | ) 213 | .toSignal(false) 214 | .map { if (_) 20.0 else 0.0 } 215 | .spring 216 | .px 217 | ) 218 | ), 219 | div( 220 | opacity <-- Animation.from(0).wait(1000).to(1).run, 221 | lineHeight("1.5"), 222 | display.flex, 223 | height("40px"), 224 | fontSize("14px"), 225 | div(s"POP.${nbsp}", opacity(0.7)), 226 | AnimatedCount(populationStatsVar.signal.map(_.connectedUsers)) 227 | ) 228 | ), 229 | renderSlides 230 | ) 231 | ) 232 | } 233 | 234 | val $slideState: Signal[SlideState] = slideStateVar.signal.combineWithFn(slideIndexOverride) { 235 | case (ss, Some(SlideIndex(slide, step))) => ss.copy(slide, ss.slideStepMap.updated(slide, step)) 236 | case (ss, None) => ss 237 | } 238 | 239 | def renderSlides: Div = div( 240 | position.relative, 241 | Slide.exampleSlides.zipWithIndex.map { case (slide, index) => 242 | val $step = $slideState.map(_.stepForSlide(index)) 243 | val $delta = $slideState.map(_.slideIndex - index) 244 | 245 | div( 246 | position.absolute, 247 | textAlign.center, 248 | width("100%"), 249 | div( 250 | margin("0 auto"), 251 | width("80%"), 252 | padding("24px"), 253 | position.relative, 254 | cls <-- $delta.map { d => 255 | if (d == 0) "slide-current slide" 256 | else if (d > 0) "slide-next slide" 257 | else "slide-previous slide" 258 | }, 259 | slide.render($step) 260 | ) 261 | ) 262 | } 263 | ) 264 | } 265 | -------------------------------------------------------------------------------- /frontend/src/main/scala/zio/slides/Step.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import com.raquo.airstream.core.Signal 4 | 5 | case class Step($step: Signal[Int]) { 6 | def equalTo(int: Int): Signal[Boolean] = 7 | $step.map(_ == int) 8 | 9 | def lessThan(int: Int): Signal[Boolean] = 10 | $step.map(_ < int) 11 | 12 | def greaterThan(int: Int): Signal[Boolean] = 13 | $step.map(_ > int) 14 | 15 | def between(min: Int, max: Int): Signal[Boolean] = 16 | $step.map(step => step >= min && step <= max) 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/main/scala/zio/slides/Styles.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import com.raquo.laminar.api.L._ 4 | 5 | object Styles { 6 | def panelStyles(isVisible: Signal[Boolean] = Val(false)) = 7 | Seq( 8 | cls <-- isVisible.map { if (_) "panel-visible" else "panel-hidden" }, 9 | height <-- isVisible.map { if (_) "auto" else "0px" }, 10 | padding <-- isVisible.map { if (_) "24px" else "0px" }, 11 | background("black"), 12 | borderTop("1px solid #333"), 13 | borderTopWidth <-- isVisible.map { if (_) "1px" else "0px" } 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/main/scala/zio/slides/VoteModule.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import animus.SignalOps 4 | import com.raquo.laminar.api.L._ 5 | import zio.slides.State.{userIdVar, voteStateVar} 6 | 7 | object VoteModule { 8 | lazy val voteBus: EventBus[UserCommand.SendVote] = new EventBus 9 | 10 | lazy val exampleOptions: List[String] = List( 11 | "Full Stack Architecture", 12 | "Scala.js & Laminar", 13 | "WebSocket Communication", 14 | "" 15 | ) 16 | 17 | lazy val exampleTopic: VoteState.Topic = VoteState.Topic("Scala-Topic") 18 | 19 | def VotesView( 20 | topic: VoteState.Topic = exampleTopic, 21 | options: List[String] = exampleOptions, 22 | $active: Signal[Boolean] = Val(true) 23 | ): Div = 24 | div( 25 | transform <-- $active.map { if (_) 1.0 else 0.95 }.spring.map { s => s"scale($s)" }, 26 | border("1px solid orange"), 27 | borderWidth <-- $active.map { if (_) 0.0 else 2.0 }.spring.px, 28 | div( 29 | "DONE", 30 | display.flex, 31 | alignItems.center, 32 | justifyContent.center, 33 | fontWeight.bold, 34 | fontSize.medium, 35 | background("orange"), 36 | overflowY.hidden, 37 | height <-- $active.map { if (_) 0.0 else 28.0 }.spring.px 38 | ), 39 | options.map { string => 40 | VoteView(topic, VoteState.Vote(string), $active) 41 | } 42 | ) 43 | 44 | private def VoteView(topic: VoteState.Topic, vote: VoteState.Vote, $active: Signal[Boolean]): Div = { 45 | val $totalVotes = voteStateVar.signal.map(_.voteTotals(topic).getOrElse(vote, 0)) 46 | val $numberOfCastVotes = voteStateVar.signal.map(_.voteTotals(topic).values.sum) 47 | 48 | val $votePercentage = $totalVotes.combineWithFn($numberOfCastVotes) { (votes, all) => 49 | if (all == 0) 0.0 50 | else votes.toDouble / all.toDouble 51 | } 52 | 53 | div( 54 | padding("8px"), 55 | border("1px solid #444"), 56 | position.relative, 57 | background("#223"), 58 | div( 59 | cls("vote-vote"), 60 | position.absolute, 61 | zIndex(1), 62 | left("0"), 63 | top("0"), 64 | right("0"), 65 | bottom("0"), 66 | width <-- $votePercentage.map(d => s"${d * 100}%"), 67 | background("blue") 68 | ), 69 | div( 70 | position.relative, 71 | vote.string, 72 | " ", 73 | span( 74 | opacity(0.6), 75 | child.text <-- $totalVotes 76 | ), 77 | zIndex(2) 78 | ), 79 | composeEvents(onMouseEnter)(_.withCurrentValueOf($active).filter(_._2)) --> { _ => 80 | voteBus.emit(UserCommand.SendVote(topic, vote)) 81 | userIdVar.now().foreach { userId => 82 | voteStateVar.update(_.processUpdate(VoteState.CastVoteId(userId, topic, vote))) 83 | } 84 | } 85 | ) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /frontend/src/main/static/stylesheets/main.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,900&display=swap'); 2 | 3 | 4 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 5 | 6 | /* Document 7 | ========================================================================== */ 8 | 9 | /** 10 | * 1. Correct the line height in all browsers. 11 | * 2. Prevent adjustments of font size after orientation changes in iOS. 12 | */ 13 | 14 | html { 15 | line-height: 1.15; /* 1 */ 16 | -webkit-text-size-adjust: 100%; /* 2 */ 17 | } 18 | 19 | /* Sections 20 | ========================================================================== */ 21 | 22 | /** 23 | * Remove the margin in all browsers. 24 | */ 25 | 26 | body { 27 | font-family: "Source Code Pro", BlinkMacSystemFont, sans-serif; 28 | font-size: 28px; 29 | background: #333 !important; 30 | color: white; 31 | box-sizing: border-box; 32 | margin: 40px; 33 | } 34 | 35 | 36 | button { 37 | cursor: pointer; 38 | border: 1px solid #555; 39 | border-radius: 4px; 40 | color: rgba(255,255,255,0.9); 41 | font-size: 18px !important; 42 | outline: none; 43 | background: #223; 44 | width: 100%; 45 | font-weight: bold; 46 | 47 | &.cancel { 48 | background: #222; 49 | } 50 | } 51 | 52 | textarea { 53 | background: #222; 54 | margin-bottom: 12px !important; 55 | border: 1px solid #333; 56 | border-radius: 4px; 57 | padding: 8px; 58 | outline: none; 59 | color: white; 60 | width: 100%; 61 | resize: none; 62 | font-size: 22px !important; 63 | } 64 | 65 | form { 66 | padding: 20px; 67 | border-radius: 4px; 68 | background: #111; 69 | border: 1px solid #222; 70 | } 71 | 72 | .all-questions { 73 | max-height: 300px; 74 | overflow-y: scroll; 75 | } 76 | 77 | .vote-vote { 78 | transition: width 0.5s; 79 | } 80 | 81 | 82 | .slide { 83 | transition: all 0.3s 0.0s; 84 | position: relative; 85 | } 86 | 87 | 88 | .panel-visible { 89 | transition: all 0.5s 0.0s; 90 | opacity: 1; 91 | position: relative; 92 | transform: translateY(0px); 93 | } 94 | 95 | .panel-hidden { 96 | transition: all 0.3s 0.0s; 97 | opacity: 0; 98 | position: relative; 99 | transform: translateY(50px); 100 | } 101 | 102 | .slide-next, .slide-previous { 103 | opacity: 0; 104 | transform: rotateY(90deg); 105 | } 106 | 107 | .slide-app { 108 | transition: all 0.4s; 109 | border-radius: 0px; 110 | border: 1px solid #000; 111 | margin: 0px; 112 | } 113 | 114 | .slide-app-shrink { 115 | transition: all 0.8s; 116 | border-radius: 8px; 117 | border: 1px solid #555; 118 | margin: 24px; 119 | } 120 | 121 | 122 | .hidden { 123 | transition: all 0.4s; 124 | opacity: 0; 125 | } 126 | 127 | .visible { 128 | opacity: 1; 129 | transition: all 0.4s; 130 | } 131 | 132 | .slide-current { 133 | left: 0px; 134 | z-index: 10; 135 | transition: all 0.4s; 136 | transform: rotateY(0deg); 137 | } 138 | 139 | .slide-next { 140 | left: -80px; 141 | // transform: rotateZ(-4deg); 142 | transform: rotateY(-90deg); 143 | } 144 | 145 | .slide-previous { 146 | left: 80px; 147 | } 148 | 149 | .input-group { 150 | + .input-group { 151 | margin-top: 24px; 152 | } 153 | 154 | input { 155 | font-size: 22px; 156 | padding: 8px; 157 | } 158 | } 159 | 160 | 161 | label { 162 | text-transform: uppercase; 163 | font-size: 14px; 164 | opacity: 0.8; 165 | display: block; 166 | margin-bottom: 8px; 167 | } 168 | 169 | input { 170 | background: #222; 171 | border: 1px solid #333; 172 | border-radius: 4px; 173 | margin-bottom: 16px; 174 | color: white; 175 | 176 | } 177 | 178 | ::placeholder { 179 | color: rgba(255,255,255,0.25); 180 | } 181 | 182 | /** 183 | * Render the `main` element consistently in IE. 184 | */ 185 | 186 | main { 187 | display: block; 188 | } 189 | 190 | /** 191 | * Correct the font size and margin on `h1` elements within `section` and 192 | * `article` contexts in Chrome, Firefox, and Safari. 193 | */ 194 | 195 | h1 { 196 | font-size: 2em; 197 | margin: 0.67em 0; 198 | } 199 | 200 | /* Grouping content 201 | ========================================================================== */ 202 | 203 | /** 204 | * 1. Add the correct box sizing in Firefox. 205 | * 2. Show the overflow in Edge and IE. 206 | */ 207 | 208 | hr { 209 | box-sizing: content-box; /* 1 */ 210 | height: 0; /* 1 */ 211 | overflow: visible; /* 2 */ 212 | } 213 | 214 | /** 215 | * 1. Correct the inheritance and scaling of font size in all browsers. 216 | * 2. Correct the odd `em` font sizing in all browsers. 217 | */ 218 | 219 | pre { 220 | font-family: "Source Code Pro", monospace, monospace; /* 1 */ 221 | font-size: 1em; /* 2 */ 222 | } 223 | 224 | /* Text-level semantics 225 | ========================================================================== */ 226 | 227 | /** 228 | * Remove the gray background on active links in IE 10. 229 | */ 230 | 231 | a { 232 | background-color: transparent; 233 | } 234 | 235 | /** 236 | * 1. Remove the bottom border in Chrome 57- 237 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 238 | */ 239 | 240 | abbr[title] { 241 | border-bottom: none; /* 1 */ 242 | text-decoration: underline; /* 2 */ 243 | text-decoration: underline dotted; /* 2 */ 244 | } 245 | 246 | /** 247 | * Add the correct font weight in Chrome, Edge, and Safari. 248 | */ 249 | 250 | b, 251 | strong { 252 | font-weight: bolder; 253 | } 254 | 255 | /** 256 | * 1. Correct the inheritance and scaling of font size in all browsers. 257 | * 2. Correct the odd `em` font sizing in all browsers. 258 | */ 259 | 260 | code, 261 | kbd, 262 | samp { 263 | font-family: monospace, monospace; /* 1 */ 264 | font-size: 1em; /* 2 */ 265 | } 266 | 267 | /** 268 | * Add the correct font size in all browsers. 269 | */ 270 | 271 | small { 272 | font-size: 80%; 273 | } 274 | 275 | /** 276 | * Prevent `sub` and `sup` elements from affecting the line height in 277 | * all browsers. 278 | */ 279 | 280 | sub, 281 | sup { 282 | font-size: 75%; 283 | line-height: 0; 284 | position: relative; 285 | vertical-align: baseline; 286 | } 287 | 288 | sub { 289 | bottom: -0.25em; 290 | } 291 | 292 | sup { 293 | top: -0.5em; 294 | } 295 | 296 | /* Embedded content 297 | ========================================================================== */ 298 | 299 | /** 300 | * Remove the border on images inside links in IE 10. 301 | */ 302 | 303 | img { 304 | border-style: none; 305 | } 306 | 307 | /* Forms 308 | ========================================================================== */ 309 | 310 | /** 311 | * 1. Change the font styles in all browsers. 312 | * 2. Remove the margin in Firefox and Safari. 313 | */ 314 | 315 | button, 316 | input, 317 | optgroup, 318 | select, 319 | textarea { 320 | font-family: inherit; /* 1 */ 321 | font-size: 100%; /* 1 */ 322 | line-height: 1.15; /* 1 */ 323 | margin: 0; /* 2 */ 324 | } 325 | 326 | /** 327 | * Show the overflow in IE. 328 | * 1. Show the overflow in Edge. 329 | */ 330 | 331 | button, 332 | input { /* 1 */ 333 | overflow: visible; 334 | } 335 | 336 | /** 337 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 338 | * 1. Remove the inheritance of text transform in Firefox. 339 | */ 340 | 341 | button, 342 | select { /* 1 */ 343 | text-transform: none; 344 | } 345 | 346 | /** 347 | * Correct the inability to style clickable types in iOS and Safari. 348 | */ 349 | 350 | button, 351 | [type="button"], 352 | [type="reset"], 353 | [type="submit"] { 354 | -webkit-appearance: button; 355 | } 356 | 357 | /** 358 | * Remove the inner border and padding in Firefox. 359 | */ 360 | 361 | button::-moz-focus-inner, 362 | [type="button"]::-moz-focus-inner, 363 | [type="reset"]::-moz-focus-inner, 364 | [type="submit"]::-moz-focus-inner { 365 | border-style: none; 366 | padding: 0; 367 | } 368 | 369 | /** 370 | * Restore the focus styles unset by the previous rule. 371 | */ 372 | 373 | button:-moz-focusring, 374 | [type="button"]:-moz-focusring, 375 | [type="reset"]:-moz-focusring, 376 | [type="submit"]:-moz-focusring { 377 | outline: 1px dotted ButtonText; 378 | } 379 | 380 | /** 381 | * Correct the padding in Firefox. 382 | */ 383 | 384 | fieldset { 385 | padding: 0.35em 0.75em 0.625em; 386 | } 387 | 388 | /** 389 | * 1. Correct the text wrapping in Edge and IE. 390 | * 2. Correct the color inheritance from `fieldset` elements in IE. 391 | * 3. Remove the padding so developers are not caught out when they zero out 392 | * `fieldset` elements in all browsers. 393 | */ 394 | 395 | legend { 396 | box-sizing: border-box; /* 1 */ 397 | color: inherit; /* 2 */ 398 | display: table; /* 1 */ 399 | max-width: 100%; /* 1 */ 400 | padding: 0; /* 3 */ 401 | white-space: normal; /* 1 */ 402 | } 403 | 404 | /** 405 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 406 | */ 407 | 408 | progress { 409 | vertical-align: baseline; 410 | } 411 | 412 | /** 413 | * Remove the default vertical scrollbar in IE 10+. 414 | */ 415 | 416 | textarea { 417 | overflow: auto; 418 | } 419 | 420 | /** 421 | * 1. Add the correct box sizing in IE 10. 422 | * 2. Remove the padding in IE 10. 423 | */ 424 | 425 | [type="checkbox"], 426 | [type="radio"] { 427 | box-sizing: border-box; /* 1 */ 428 | padding: 0; /* 2 */ 429 | } 430 | 431 | /** 432 | * Correct the cursor style of increment and decrement buttons in Chrome. 433 | */ 434 | 435 | [type="number"]::-webkit-inner-spin-button, 436 | [type="number"]::-webkit-outer-spin-button { 437 | height: auto; 438 | } 439 | 440 | /** 441 | * 1. Correct the odd appearance in Chrome and Safari. 442 | * 2. Correct the outline style in Safari. 443 | */ 444 | 445 | [type="search"] { 446 | -webkit-appearance: textfield; /* 1 */ 447 | outline-offset: -2px; /* 2 */ 448 | } 449 | 450 | /** 451 | * Remove the inner padding in Chrome and Safari on macOS. 452 | */ 453 | 454 | [type="search"]::-webkit-search-decoration { 455 | -webkit-appearance: none; 456 | } 457 | 458 | /** 459 | * 1. Correct the inability to style clickable types in iOS and Safari. 460 | * 2. Change font properties to `inherit` in Safari. 461 | */ 462 | 463 | ::-webkit-file-upload-button { 464 | -webkit-appearance: button; /* 1 */ 465 | font: inherit; /* 2 */ 466 | } 467 | 468 | /* Interactive 469 | ========================================================================== */ 470 | 471 | /* 472 | * Add the correct display in Edge, IE 10+, and Firefox. 473 | */ 474 | 475 | details { 476 | display: block; 477 | } 478 | 479 | /* 480 | * Add the correct display in all browsers. 481 | */ 482 | 483 | summary { 484 | display: list-item; 485 | } 486 | 487 | /* Misc 488 | ========================================================================== */ 489 | 490 | /** 491 | * Add the correct display in IE 10+. 492 | */ 493 | 494 | template { 495 | display: none; 496 | } 497 | 498 | /** 499 | * Add the correct display in IE 10. 500 | */ 501 | 502 | [hidden] { 503 | display: none; 504 | } -------------------------------------------------------------------------------- /frontend/src/test/scala/zio/slides/VoteStateSpec.scala: -------------------------------------------------------------------------------- 1 | //package zio.slides 2 | // 3 | //import zio.random.Random 4 | //import zio.slides.VoteState.{CastVoteId, Topic, UserId, Vote} 5 | //import zio.test.Assertion.{anything, equalTo, isLessThan, isLessThanEqualTo} 6 | //import zio.test._ 7 | //import zio.test.magnolia.DeriveGen 8 | // 9 | //object VoteStateSpec extends DefaultRunnableSpec { 10 | // 11 | // val voteGen: Gen[Random with Sized, Vote] = Gen.elements(List.tabulate(3)(n => Vote(s"vote-${n + 1}")): _*) 12 | // val topicGen: Gen[Random with Sized, Topic] = Gen.elements(List.tabulate(3)(n => Topic(s"topic-${n + 1}")): _*) 13 | // val userIdGen: Gen[Random with Sized, UserId] = Gen.elements(List.tabulate(20)(n => UserId(s"user-id-${n + 1}")): _*) 14 | // val castVotesGen: Gen[Random with Sized, CastVoteId] = Gen.zipN(userIdGen, topicGen, voteGen)(CastVoteId(_, _, _)) 15 | // 16 | // override def spec = suite("VoteState")( 17 | // testM("vote totals per topic can never exceed the number of users") { 18 | // check(Gen.listOf(castVotesGen)) { votes => 19 | // val voteState = votes.foldLeft(VoteState.empty)(_.processUpdate(_)) 20 | // val users = votes.map(_.id).toSet 21 | // val topics = votes.map(_.topic).toSet 22 | // 23 | // BoolAlgebra 24 | // .all( 25 | // topics.map { topic => 26 | // val totalVotes = voteState.voteTotals(topic).values.sum 27 | // assert(totalVotes)(isLessThanEqualTo(users.size)) 28 | // }.toList 29 | // ) 30 | // .getOrElse(assert(1)(anything)) 31 | // } 32 | // }, 33 | // test("processes votes") { 34 | // val userA = UserId("A") 35 | // val userB = UserId("B") 36 | // val userC = UserId("C") 37 | // 38 | // val topicRed = Topic("Red") 39 | // val topicBlue = Topic("Blue") 40 | // 41 | // val votes = List( 42 | // CastVoteId(userC, topicRed, Vote("2")), 43 | // CastVoteId(userA, topicRed, Vote("3")), 44 | // CastVoteId(userA, topicBlue, Vote("1")), 45 | // CastVoteId(userA, topicBlue, Vote("2")), 46 | // CastVoteId(userB, topicBlue, Vote("1")), 47 | // CastVoteId(userA, topicRed, Vote("1")), 48 | // CastVoteId(userC, topicBlue, Vote("3")), 49 | // CastVoteId(userB, topicRed, Vote("2")) 50 | // ) 51 | // 52 | // val voteState = votes.foldLeft(VoteState.empty)(_.processUpdate(_)) 53 | // 54 | // assert(voteState.voteTotals(topicRed))(equalTo(Map(Vote("1") -> 1, Vote("2") -> 2))) && 55 | // assert(voteState.voteTotals(topicBlue))(equalTo(Map(Vote("1") -> 1, Vote("2") -> 1, Vote("3") -> 1))) 56 | // } 57 | // ) 58 | // 59 | //} 60 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Zymposium 5 | 6 | 7 | 8 |
9 | 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "formula", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "devDependencies": { 7 | "sass": "^1.32.8", 8 | "vite": "^2.2.1", 9 | "vite-plugin-html": "^2.0.3" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.4.7 -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.10.0") 2 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.8.1") 3 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") 4 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.34") 5 | -------------------------------------------------------------------------------- /shared/src/main/scala/zio/slides/ClientCommand.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import java.util.UUID 4 | 5 | sealed trait ClientCommand 6 | 7 | sealed trait UserCommand extends ClientCommand 8 | 9 | object UserCommand { 10 | case object Subscribe extends UserCommand 11 | case class AskQuestion(question: String, slideIndex: SlideIndex) extends UserCommand 12 | case class SendVote(topic: VoteState.Topic, vote: VoteState.Vote) extends UserCommand 13 | } 14 | 15 | sealed trait AdminCommand extends ClientCommand 16 | 17 | object AdminCommand { 18 | case object NextSlide extends AdminCommand 19 | case object PrevSlide extends AdminCommand 20 | case object NextStep extends AdminCommand 21 | case object PrevStep extends AdminCommand 22 | case class ToggleQuestion(id: UUID) extends AdminCommand 23 | } 24 | -------------------------------------------------------------------------------- /shared/src/main/scala/zio/slides/Question.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import java.util.UUID 4 | 5 | case class SlideIndex(slide: Int, step: Int) { 6 | def show: String = s"($slide,$step)" 7 | } 8 | 9 | case class Question(id: UUID, question: String, slideIndex: SlideIndex) 10 | 11 | object Question { 12 | def apply(question: String, slideIndex: SlideIndex): Question = 13 | new Question(UUID.randomUUID(), question, slideIndex) 14 | } 15 | -------------------------------------------------------------------------------- /shared/src/main/scala/zio/slides/QuestionState.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import java.util.UUID 4 | 5 | case class QuestionState( 6 | questions: Vector[Question] = Vector.empty, 7 | activeQuestionId: Option[UUID] = None 8 | ) { 9 | 10 | def activeQuestion: Option[Question] = questions.find(q => activeQuestionId.contains(q.id)) 11 | 12 | def toggleQuestion(qid: UUID): QuestionState = 13 | if (activeQuestionId.contains(qid)) copy(activeQuestionId = None) 14 | else copy(activeQuestionId = Some(qid)) 15 | 16 | def askQuestion(question: String, slideIndex: SlideIndex): QuestionState = 17 | copy(questions = questions.appended(Question(question, slideIndex))) 18 | } 19 | 20 | object QuestionState { 21 | def empty: QuestionState = QuestionState() 22 | } 23 | -------------------------------------------------------------------------------- /shared/src/main/scala/zio/slides/ServerCommand.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | 4 | sealed trait ServerCommand 5 | 6 | object ServerCommand { 7 | case class SendSlideState(slideState: SlideState) extends ServerCommand 8 | case class SendQuestionState(questionState: QuestionState) extends ServerCommand 9 | case class SendVotes(votes: Vector[VoteState.CastVoteId]) extends ServerCommand 10 | case class SendUserId(id: VoteState.UserId) extends ServerCommand 11 | case class SendPopulationStats(populationStats: PopulationStats) extends ServerCommand 12 | } 13 | -------------------------------------------------------------------------------- /shared/src/main/scala/zio/slides/SlideState.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | case class PopulationStats(connectedUsers: Int) { 4 | def addOne: PopulationStats = 5 | copy(connectedUsers = connectedUsers + 1) 6 | 7 | def removeOne: PopulationStats = 8 | copy(connectedUsers = connectedUsers - 1) 9 | } 10 | 11 | case class SlideState(slideIndex: Int, slideStepMap: Map[Int, Int]) { 12 | def stepIndex: Int = stepForSlide(slideIndex) 13 | 14 | def prevSlide: SlideState = copy(slideIndex = (slideIndex - 1) max 0) 15 | def nextSlide: SlideState = copy(slideIndex = slideIndex + 1) 16 | 17 | def prevStep: SlideState = 18 | copy(slideStepMap = slideStepMap.updated(slideIndex, Math.max(0, stepForSlide(slideIndex) - 1))) 19 | def nextStep: SlideState = 20 | copy(slideStepMap = slideStepMap.updated(slideIndex, stepForSlide(slideIndex) + 1)) 21 | 22 | def stepForSlide(slideIndex0: Int): Int = 23 | slideStepMap.getOrElse(slideIndex0, 0) 24 | } 25 | 26 | object SlideState { 27 | def empty: SlideState = 28 | SlideState(0, Map.empty) 29 | 30 | def random: SlideState = { 31 | val randomSlide = scala.util.Random.nextInt(3) 32 | val randomStep = scala.util.Random.nextInt(3) 33 | SlideState(randomSlide, Map(randomSlide -> randomStep)) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /shared/src/main/scala/zio/slides/VoteState.scala: -------------------------------------------------------------------------------- 1 | package zio.slides 2 | 3 | import zio.slides.VoteState.{CastVoteId, Topic, Vote, VoteMap} 4 | 5 | import java.util.UUID 6 | 7 | case class VoteState private (map: VoteMap) { self => 8 | def processUpdates(votes: Vector[CastVoteId]): VoteState = 9 | votes.foldLeft(self)(_.processUpdate(_)) 10 | 11 | def processUpdate(vote: CastVoteId): VoteState = 12 | VoteState { 13 | map.updatedWith(vote.topic) { 14 | case Some(idVotes) => Some(idVotes.updated(vote.id, vote.vote)) 15 | case None => Some(Map(vote.id -> vote.vote)) 16 | } 17 | } 18 | 19 | def voteTotals(topic: Topic): Map[Vote, Int] = 20 | map.getOrElse(topic, Map.empty).toList.groupBy(_._2).view.mapValues(_.length).toMap 21 | } 22 | 23 | object VoteState { 24 | def empty: VoteState = new VoteState(Map.empty) 25 | 26 | case class CastVote(topic: Topic, vote: Vote) 27 | case class CastVoteId(id: UserId, topic: Topic, vote: Vote) 28 | 29 | case class Topic(string: String) extends AnyVal 30 | 31 | case class UserId(string: String) extends AnyVal 32 | 33 | object UserId { 34 | def random: UserId = UserId(UUID.randomUUID().toString) 35 | } 36 | 37 | case class Vote(string: String) extends AnyVal 38 | 39 | type VoteMap = Map[Topic, Map[UserId, Vote]] 40 | } 41 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | 3 | const scalaVersion = '2.13' 4 | // const scalaVersion = '3.0.0-RC1' 5 | 6 | // https://vitejs.dev/config/ 7 | export default ({ mode }) => { 8 | // const mainJS = `frontend/target/scala-${scalaVersion}/frontend-${mode === 'production' ? 'opt' : 'fastopt'}/main.js` 9 | // console.log('mainJS', mainJS) 10 | return { 11 | publicDir: './frontend/src/main/static/public', 12 | resolve: { 13 | alias: { 14 | 'stylesheets': resolve(__dirname, './frontend/src/main/static/stylesheets'), 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@rollup/pluginutils@^4.1.1": 6 | version "4.1.1" 7 | resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.1.1.tgz#1d4da86dd4eded15656a57d933fda2b9a08d47ec" 8 | integrity sha512-clDjivHqWGXi7u+0d2r2sBi4Ie6VLEAzWMIkvJLnDmxoOhBYOTfzGbOQBA32THHm11/LiJbd01tJUpJsbshSWQ== 9 | dependencies: 10 | estree-walker "^2.0.1" 11 | picomatch "^2.2.2" 12 | 13 | ansi-styles@^3.2.1: 14 | version "3.2.1" 15 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" 16 | integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== 17 | dependencies: 18 | color-convert "^1.9.0" 19 | 20 | anymatch@~3.1.2: 21 | version "3.1.2" 22 | resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" 23 | integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== 24 | dependencies: 25 | normalize-path "^3.0.0" 26 | picomatch "^2.0.4" 27 | 28 | async@0.9.x: 29 | version "0.9.2" 30 | resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" 31 | integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0= 32 | 33 | balanced-match@^1.0.0: 34 | version "1.0.2" 35 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" 36 | integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== 37 | 38 | binary-extensions@^2.0.0: 39 | version "2.2.0" 40 | resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" 41 | integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== 42 | 43 | brace-expansion@^1.1.7: 44 | version "1.1.11" 45 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" 46 | integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== 47 | dependencies: 48 | balanced-match "^1.0.0" 49 | concat-map "0.0.1" 50 | 51 | braces@~3.0.2: 52 | version "3.0.2" 53 | resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" 54 | integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== 55 | dependencies: 56 | fill-range "^7.0.1" 57 | 58 | buffer-from@^1.0.0: 59 | version "1.1.2" 60 | resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" 61 | integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== 62 | 63 | camel-case@^4.1.1: 64 | version "4.1.2" 65 | resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a" 66 | integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw== 67 | dependencies: 68 | pascal-case "^3.1.2" 69 | tslib "^2.0.3" 70 | 71 | chalk@^2.4.2: 72 | version "2.4.2" 73 | resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" 74 | integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== 75 | dependencies: 76 | ansi-styles "^3.2.1" 77 | escape-string-regexp "^1.0.5" 78 | supports-color "^5.3.0" 79 | 80 | "chokidar@>=3.0.0 <4.0.0": 81 | version "3.5.2" 82 | resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75" 83 | integrity sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ== 84 | dependencies: 85 | anymatch "~3.1.2" 86 | braces "~3.0.2" 87 | glob-parent "~5.1.2" 88 | is-binary-path "~2.1.0" 89 | is-glob "~4.0.1" 90 | normalize-path "~3.0.0" 91 | readdirp "~3.6.0" 92 | optionalDependencies: 93 | fsevents "~2.3.2" 94 | 95 | clean-css@^4.2.3: 96 | version "4.2.3" 97 | resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78" 98 | integrity sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA== 99 | dependencies: 100 | source-map "~0.6.0" 101 | 102 | color-convert@^1.9.0: 103 | version "1.9.3" 104 | resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" 105 | integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== 106 | dependencies: 107 | color-name "1.1.3" 108 | 109 | color-name@1.1.3: 110 | version "1.1.3" 111 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" 112 | integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= 113 | 114 | commander@^2.20.0: 115 | version "2.20.3" 116 | resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" 117 | integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== 118 | 119 | commander@^4.1.1: 120 | version "4.1.1" 121 | resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" 122 | integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== 123 | 124 | concat-map@0.0.1: 125 | version "0.0.1" 126 | resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" 127 | integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= 128 | 129 | dot-case@^3.0.4: 130 | version "3.0.4" 131 | resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" 132 | integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== 133 | dependencies: 134 | no-case "^3.0.4" 135 | tslib "^2.0.3" 136 | 137 | dotenv-expand@^5.1.0: 138 | version "5.1.0" 139 | resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0" 140 | integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA== 141 | 142 | dotenv@^10.0.0: 143 | version "10.0.0" 144 | resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" 145 | integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== 146 | 147 | ejs@^3.1.6: 148 | version "3.1.6" 149 | resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.6.tgz#5bfd0a0689743bb5268b3550cceeebbc1702822a" 150 | integrity sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw== 151 | dependencies: 152 | jake "^10.6.1" 153 | 154 | esbuild-android-arm64@0.13.6: 155 | version "0.13.6" 156 | resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.13.6.tgz#a109b4e5203e9ec144cadccdf18a5daf021423e5" 157 | integrity sha512-uEwrMRzqNzXxzIi0K/CtHn3/SPoRso4Dd/aJCpf9KuX+kCs9Tlhz29cKbZieznYAekdo36fDUrZyuugAwSdI+A== 158 | 159 | esbuild-darwin-64@0.13.6: 160 | version "0.13.6" 161 | resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.13.6.tgz#1a00ef4d2b3b1fe9de28a5cf195df113d6461155" 162 | integrity sha512-oJdWZn2QV5LTM24/vVWaUFlMVlRhpG9zZIA6Xd+xbCULOURwYnYRQWIzRpXNtTfuAr3+em9PqKUaGtYqvO/DYg== 163 | 164 | esbuild-darwin-arm64@0.13.6: 165 | version "0.13.6" 166 | resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.6.tgz#f48954d441059e2d06c1675ddcc25af00b164935" 167 | integrity sha512-+f8Yn5doTEpCWtBaGxciDTikxESdGCNZpLYtXzMJLTWFHr8zqfAf4TAYGvg6T5T6N7OMC8HHy3GM+BijFXDXMg== 168 | 169 | esbuild-freebsd-64@0.13.6: 170 | version "0.13.6" 171 | resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.6.tgz#b3bfea7e21f0d80796220927118fc76170cac06f" 172 | integrity sha512-Yb/DgZUX0C6i4vnOymthLzoWAJBYWbn3Y2F4wKEufsx2veGN/wlwO/yz7IWGVVzb2zMUqbt30hCLF61sUFe7gA== 173 | 174 | esbuild-freebsd-arm64@0.13.6: 175 | version "0.13.6" 176 | resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.6.tgz#e6f5777a85012457ada049fc6b1e3e2c36161514" 177 | integrity sha512-UKYlEb7mwprSJ9VW9+q3/Mgxest45I6rGMB/hrKY1T6lqoBVhWS4BTbL4EGetWdk05Tw4njFAO9+nmxgl7jMlA== 178 | 179 | esbuild-linux-32@0.13.6: 180 | version "0.13.6" 181 | resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.13.6.tgz#8b04058312a76faec6964b954f1f02ab32ce43fe" 182 | integrity sha512-hQCZfSLBYtn8f1afFT6Dh9KeLsW12xLqrqssbhpi/xfN9c/bbCh/QQZaR9ZOEnmBHHRPb7rbSo3jQqlCWYb7LQ== 183 | 184 | esbuild-linux-64@0.13.6: 185 | version "0.13.6" 186 | resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.13.6.tgz#554d8edfe3f791f8b26978eb173b2e13643442c0" 187 | integrity sha512-bRQwsD+xJoajonfyeq5JpiNRogH4mYFYbYsGhwrtQ4pMGk93V/4KuKQiKEisRZO0hYhZL4MtxufwF195zKlCAw== 188 | 189 | esbuild-linux-arm64@0.13.6: 190 | version "0.13.6" 191 | resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.6.tgz#2142fadbdbc0ebd52a166f956f0ecb1f6602112a" 192 | integrity sha512-sRc1lt9ma1xBvInCwpS77ywR6KVdcJNsErsrDkDXx3mVe8DLLEn05TG0nIX9I+s8ouHEepikdKCfe1DZdILRjQ== 193 | 194 | esbuild-linux-arm@0.13.6: 195 | version "0.13.6" 196 | resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.13.6.tgz#ced8e35a94e0adbf134e5fa4e2b661f897e14b27" 197 | integrity sha512-qQUrpL7QoPqujXEFSpeu6QZ43z0+OdDPHDkLO0GPbpV/jebP7J+0FreMqoq7ZxWG4rPigwcRdEyqzHh8Bh4Faw== 198 | 199 | esbuild-linux-mips64le@0.13.6: 200 | version "0.13.6" 201 | resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.6.tgz#e5cbc050f5d44f8ecc0f79b1641bbad3919a2b3a" 202 | integrity sha512-1lsHZaIsHlFkHn1QRa/EONPGVHwzdIrkKn6r2m9cYUIn2J+rKtJg0e+WkNG3MaIrxozaGKaiSPGvaG1toCbZjw== 203 | 204 | esbuild-linux-ppc64le@0.13.6: 205 | version "0.13.6" 206 | resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.6.tgz#57868a7eb762c1d19fa6d367b09a4610f0cbf7ca" 207 | integrity sha512-x223JNC8XeLDf05zLaKfxqCEWVct4frp8ft8Qc13cha33TMrqMFaSPq6cgpgT2VYuUsXtwoocoWChKfvy+AUQg== 208 | 209 | esbuild-netbsd-64@0.13.6: 210 | version "0.13.6" 211 | resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.6.tgz#1c5daa62571f1065e4a1100a1db5e488ef259024" 212 | integrity sha512-TonKf530kT25+zi1Da6esITmuBJe13QiN+QGVch6YE8t720IvIelDGwkOQN3Td7A0JjbSbK3u+Fo6YaL151VxQ== 213 | 214 | esbuild-openbsd-64@0.13.6: 215 | version "0.13.6" 216 | resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.6.tgz#315fd85970365835f6a1eb7b6e9335d59f772564" 217 | integrity sha512-WFa5J0IuyER0UJbCGw87gvGWXGfhxeNppYcvQjp0pWYuH4FS+YqphyjV0RJlybzzDpAXkyZ9RzkMFtSAp+6AUA== 218 | 219 | esbuild-sunos-64@0.13.6: 220 | version "0.13.6" 221 | resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.13.6.tgz#8422eeb9f3712daa4befd19e5da6d7c9af9fc744" 222 | integrity sha512-duCL8Ewri+zjKxuN/61maniDxcd8fHwSuubdAPofll0y0E6WcL/R/e/mQzhHIuoguFm5RJkKun1qua54javh7g== 223 | 224 | esbuild-windows-32@0.13.6: 225 | version "0.13.6" 226 | resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.13.6.tgz#694eb4768ee72219d3bc6415b1d3a0f843aea9ec" 227 | integrity sha512-U8RkpT4f0/dygA5ytFyHNZ/fRECU9LWBMrqWflNhM31iTi6RhU0QTuOzFYkmpYnwl358ZZhVoBeEOm313d4u4A== 228 | 229 | esbuild-windows-64@0.13.6: 230 | version "0.13.6" 231 | resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.13.6.tgz#1adbf5367b08e735262f57098d19c07d0a2fec1c" 232 | integrity sha512-A23VyUeyBfSWUYNL0jtrJi5M/2yR/RR8zfpGQ0wU0fldqV2vxnvmBYOBwRxexFYCDRpRWh4cPFsoYoXRCFa8Dg== 233 | 234 | esbuild-windows-arm64@0.13.6: 235 | version "0.13.6" 236 | resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.6.tgz#9279083740ec90a2d638485c97b1d003771d685a" 237 | integrity sha512-K/pFqK/s5C6wXYcFKO9iPY4yU3DI0/Gbl1W2+OhaPHoXu13VGBmqbCiQ5lohHGE72FFQl76naOjEayEiI+gDMQ== 238 | 239 | esbuild@^0.13.2: 240 | version "0.13.6" 241 | resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.13.6.tgz#b9be288108d47e814a6c8729e495dce0fddbf441" 242 | integrity sha512-zkMkYwC9ohVe6qxXykKf/4jfbtM/09CL8UEEnwuhO7Xq8NOTN2yAwCrmKKvHlGrEej6Y8e/tAmHB7wMMg7O0ew== 243 | optionalDependencies: 244 | esbuild-android-arm64 "0.13.6" 245 | esbuild-darwin-64 "0.13.6" 246 | esbuild-darwin-arm64 "0.13.6" 247 | esbuild-freebsd-64 "0.13.6" 248 | esbuild-freebsd-arm64 "0.13.6" 249 | esbuild-linux-32 "0.13.6" 250 | esbuild-linux-64 "0.13.6" 251 | esbuild-linux-arm "0.13.6" 252 | esbuild-linux-arm64 "0.13.6" 253 | esbuild-linux-mips64le "0.13.6" 254 | esbuild-linux-ppc64le "0.13.6" 255 | esbuild-netbsd-64 "0.13.6" 256 | esbuild-openbsd-64 "0.13.6" 257 | esbuild-sunos-64 "0.13.6" 258 | esbuild-windows-32 "0.13.6" 259 | esbuild-windows-64 "0.13.6" 260 | esbuild-windows-arm64 "0.13.6" 261 | 262 | escape-string-regexp@^1.0.5: 263 | version "1.0.5" 264 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 265 | integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= 266 | 267 | estree-walker@^2.0.1: 268 | version "2.0.2" 269 | resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" 270 | integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== 271 | 272 | filelist@^1.0.1: 273 | version "1.0.2" 274 | resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.2.tgz#80202f21462d4d1c2e214119b1807c1bc0380e5b" 275 | integrity sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ== 276 | dependencies: 277 | minimatch "^3.0.4" 278 | 279 | fill-range@^7.0.1: 280 | version "7.0.1" 281 | resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" 282 | integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== 283 | dependencies: 284 | to-regex-range "^5.0.1" 285 | 286 | fs-extra@^10.0.0: 287 | version "10.0.0" 288 | resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.0.0.tgz#9ff61b655dde53fb34a82df84bb214ce802e17c1" 289 | integrity sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ== 290 | dependencies: 291 | graceful-fs "^4.2.0" 292 | jsonfile "^6.0.1" 293 | universalify "^2.0.0" 294 | 295 | fsevents@~2.3.2: 296 | version "2.3.2" 297 | resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" 298 | integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== 299 | 300 | function-bind@^1.1.1: 301 | version "1.1.1" 302 | resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" 303 | integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== 304 | 305 | glob-parent@~5.1.2: 306 | version "5.1.2" 307 | resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" 308 | integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== 309 | dependencies: 310 | is-glob "^4.0.1" 311 | 312 | graceful-fs@^4.1.6, graceful-fs@^4.2.0: 313 | version "4.2.8" 314 | resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" 315 | integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== 316 | 317 | has-flag@^3.0.0: 318 | version "3.0.0" 319 | resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" 320 | integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= 321 | 322 | has@^1.0.3: 323 | version "1.0.3" 324 | resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" 325 | integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== 326 | dependencies: 327 | function-bind "^1.1.1" 328 | 329 | he@^1.2.0: 330 | version "1.2.0" 331 | resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" 332 | integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== 333 | 334 | html-minifier-terser@^5.1.1: 335 | version "5.1.1" 336 | resolved "https://registry.yarnpkg.com/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz#922e96f1f3bb60832c2634b79884096389b1f054" 337 | integrity sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg== 338 | dependencies: 339 | camel-case "^4.1.1" 340 | clean-css "^4.2.3" 341 | commander "^4.1.1" 342 | he "^1.2.0" 343 | param-case "^3.0.3" 344 | relateurl "^0.2.7" 345 | terser "^4.6.3" 346 | 347 | is-binary-path@~2.1.0: 348 | version "2.1.0" 349 | resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" 350 | integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== 351 | dependencies: 352 | binary-extensions "^2.0.0" 353 | 354 | is-core-module@^2.2.0: 355 | version "2.8.0" 356 | resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.0.tgz#0321336c3d0925e497fd97f5d95cb114a5ccd548" 357 | integrity sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw== 358 | dependencies: 359 | has "^1.0.3" 360 | 361 | is-extglob@^2.1.1: 362 | version "2.1.1" 363 | resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" 364 | integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= 365 | 366 | is-glob@^4.0.1, is-glob@~4.0.1: 367 | version "4.0.3" 368 | resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" 369 | integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== 370 | dependencies: 371 | is-extglob "^2.1.1" 372 | 373 | is-number@^7.0.0: 374 | version "7.0.0" 375 | resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" 376 | integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== 377 | 378 | jake@^10.6.1: 379 | version "10.8.2" 380 | resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b" 381 | integrity sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A== 382 | dependencies: 383 | async "0.9.x" 384 | chalk "^2.4.2" 385 | filelist "^1.0.1" 386 | minimatch "^3.0.4" 387 | 388 | jsonfile@^6.0.1: 389 | version "6.1.0" 390 | resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" 391 | integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== 392 | dependencies: 393 | universalify "^2.0.0" 394 | optionalDependencies: 395 | graceful-fs "^4.1.6" 396 | 397 | lower-case@^2.0.2: 398 | version "2.0.2" 399 | resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" 400 | integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== 401 | dependencies: 402 | tslib "^2.0.3" 403 | 404 | minimatch@^3.0.4: 405 | version "3.0.4" 406 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" 407 | integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== 408 | dependencies: 409 | brace-expansion "^1.1.7" 410 | 411 | nanoid@^3.1.28: 412 | version "3.1.30" 413 | resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.30.tgz#63f93cc548d2a113dc5dfbc63bfa09e2b9b64362" 414 | integrity sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ== 415 | 416 | no-case@^3.0.4: 417 | version "3.0.4" 418 | resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" 419 | integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== 420 | dependencies: 421 | lower-case "^2.0.2" 422 | tslib "^2.0.3" 423 | 424 | normalize-path@^3.0.0, normalize-path@~3.0.0: 425 | version "3.0.0" 426 | resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" 427 | integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== 428 | 429 | param-case@^3.0.3: 430 | version "3.0.4" 431 | resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" 432 | integrity sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A== 433 | dependencies: 434 | dot-case "^3.0.4" 435 | tslib "^2.0.3" 436 | 437 | pascal-case@^3.1.2: 438 | version "3.1.2" 439 | resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb" 440 | integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g== 441 | dependencies: 442 | no-case "^3.0.4" 443 | tslib "^2.0.3" 444 | 445 | path-parse@^1.0.6: 446 | version "1.0.7" 447 | resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" 448 | integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== 449 | 450 | picocolors@^0.2.1: 451 | version "0.2.1" 452 | resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f" 453 | integrity sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA== 454 | 455 | picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2: 456 | version "2.3.0" 457 | resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" 458 | integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== 459 | 460 | postcss@^8.3.8: 461 | version "8.3.9" 462 | resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.9.tgz#98754caa06c4ee9eb59cc48bd073bb6bd3437c31" 463 | integrity sha512-f/ZFyAKh9Dnqytx5X62jgjhhzttjZS7hMsohcI7HEI5tjELX/HxCy3EFhsRxyzGvrzFF+82XPvCS8T9TFleVJw== 464 | dependencies: 465 | nanoid "^3.1.28" 466 | picocolors "^0.2.1" 467 | source-map-js "^0.6.2" 468 | 469 | readdirp@~3.6.0: 470 | version "3.6.0" 471 | resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" 472 | integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== 473 | dependencies: 474 | picomatch "^2.2.1" 475 | 476 | relateurl@^0.2.7: 477 | version "0.2.7" 478 | resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" 479 | integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= 480 | 481 | resolve@^1.20.0: 482 | version "1.20.0" 483 | resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" 484 | integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== 485 | dependencies: 486 | is-core-module "^2.2.0" 487 | path-parse "^1.0.6" 488 | 489 | rollup@^2.57.0: 490 | version "2.58.0" 491 | resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.58.0.tgz#a643983365e7bf7f5b7c62a8331b983b7c4c67fb" 492 | integrity sha512-NOXpusKnaRpbS7ZVSzcEXqxcLDOagN6iFS8p45RkoiMqPHDLwJm758UF05KlMoCRbLBTZsPOIa887gZJ1AiXvw== 493 | optionalDependencies: 494 | fsevents "~2.3.2" 495 | 496 | sass@^1.32.8: 497 | version "1.43.2" 498 | resolved "https://registry.yarnpkg.com/sass/-/sass-1.43.2.tgz#c02501520c624ad6622529a8b3724eb08da82d65" 499 | integrity sha512-DncYhjl3wBaPMMJR0kIUaH3sF536rVrOcqqVGmTZHQRRzj7LQlyGV7Mb8aCKFyILMr5VsPHwRYtyKpnKYlmQSQ== 500 | dependencies: 501 | chokidar ">=3.0.0 <4.0.0" 502 | 503 | source-map-js@^0.6.2: 504 | version "0.6.2" 505 | resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" 506 | integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug== 507 | 508 | source-map-support@~0.5.12: 509 | version "0.5.20" 510 | resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.20.tgz#12166089f8f5e5e8c56926b377633392dd2cb6c9" 511 | integrity sha512-n1lZZ8Ve4ksRqizaBQgxXDgKwttHDhyfQjA6YZZn8+AroHbsIz+JjwxQDxbp+7y5OYCI8t1Yk7etjD9CRd2hIw== 512 | dependencies: 513 | buffer-from "^1.0.0" 514 | source-map "^0.6.0" 515 | 516 | source-map@^0.6.0, source-map@~0.6.0, source-map@~0.6.1: 517 | version "0.6.1" 518 | resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" 519 | integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== 520 | 521 | supports-color@^5.3.0: 522 | version "5.5.0" 523 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" 524 | integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== 525 | dependencies: 526 | has-flag "^3.0.0" 527 | 528 | terser@^4.6.3: 529 | version "4.8.0" 530 | resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17" 531 | integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw== 532 | dependencies: 533 | commander "^2.20.0" 534 | source-map "~0.6.1" 535 | source-map-support "~0.5.12" 536 | 537 | to-regex-range@^5.0.1: 538 | version "5.0.1" 539 | resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" 540 | integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== 541 | dependencies: 542 | is-number "^7.0.0" 543 | 544 | tslib@^2.0.3: 545 | version "2.3.1" 546 | resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" 547 | integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== 548 | 549 | universalify@^2.0.0: 550 | version "2.0.0" 551 | resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" 552 | integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== 553 | 554 | vite-plugin-html@^2.0.3: 555 | version "2.1.1" 556 | resolved "https://registry.yarnpkg.com/vite-plugin-html/-/vite-plugin-html-2.1.1.tgz#014b44126a72d459cd460bd156800c225d124cbe" 557 | integrity sha512-TCLLYzibNDEMwbtRYAYYmI7CqMuU0qFFfoTVhEQ8w4P9Tjfe5Xrh/0+XXydifgd/H7xzuWkFUjSYT6Egy7Y27Q== 558 | dependencies: 559 | "@rollup/pluginutils" "^4.1.1" 560 | dotenv "^10.0.0" 561 | dotenv-expand "^5.1.0" 562 | ejs "^3.1.6" 563 | fs-extra "^10.0.0" 564 | html-minifier-terser "^5.1.1" 565 | 566 | vite@^2.2.1: 567 | version "2.6.7" 568 | resolved "https://registry.yarnpkg.com/vite/-/vite-2.6.7.tgz#e15c1d8327950720b5d7c4ec3fb36a5a58ccf7cb" 569 | integrity sha512-ewk//jve9k6vlU8PfJmWUHN8k0YYdw4VaKOMvoQ3nT2Pb6k5OSMKQi4jPOzVH/TlUqMsCrq7IJ80xcuDDVyigg== 570 | dependencies: 571 | esbuild "^0.13.2" 572 | postcss "^8.3.8" 573 | resolve "^1.20.0" 574 | rollup "^2.57.0" 575 | optionalDependencies: 576 | fsevents "~2.3.2" 577 | --------------------------------------------------------------------------------