├── .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 |