Skip to content

Commit

Permalink
Merge branch 'feat-online'
Browse files Browse the repository at this point in the history
  • Loading branch information
nymanjens committed May 2, 2020
2 parents d9436a8 + ba1484d commit 0824129
Show file tree
Hide file tree
Showing 31 changed files with 1,007 additions and 317 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ This is a web-app to run locally while conducting a quiz (in real life) with abo

This quiz can be played in different ways, which will inform the quiz settings (http://localhost:9000/app/quizsettings/*).

- With every team using a phone/tablet/laptop connected to `http://<your-ip-address>:9000/app/teamcontroller`
- To choose from multiple-choice questions. Note that these are automatically scored.
- To fill in textual answers. Note that these are automatically scored.
- With up to 4 physical game controllers
- To choose from multiple-choice questions (in the quiz settings, choose "Answer bullet type" = Arrows). Note that these are automatically scored.
- To stop the timer and give an answer
Expand Down
8 changes: 8 additions & 0 deletions app/js/shared/src/main/scala/app/api/ScalaJsApiClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import hydro.models.modification.EntityType
import autowire._
import boopickle.Default._
import app.api.Picklers._
import app.models.quiz.QuizState.Submission.SubmissionValue
import hydro.api.PicklableDbQuery
import hydro.api.ScalaJsApiRequest
import hydro.common.JsLoggingUtils.logExceptions
Expand All @@ -27,6 +28,7 @@ trait ScalaJsApiClient {
def persistEntityModifications(modifications: Seq[EntityModification]): Future[Unit]
def executeDataQuery[E <: Entity](dbQuery: DbQuery[E]): Future[Seq[E]]
def executeCountQuery(dbQuery: DbQuery[_ <: Entity]): Future[Int]
def addSubmission(teamId: Long, submissionValue: SubmissionValue): Future[Unit]
}

object ScalaJsApiClient {
Expand Down Expand Up @@ -58,6 +60,12 @@ object ScalaJsApiClient {
HttpPostAutowireClient[ScalaJsApi].executeCountQuery(picklableDbQuery).call()
}

override def addSubmission(teamId: Long, submissionValue: SubmissionValue) = {
HttpPostAutowireClient[ScalaJsApi]
.addSubmission(teamId, submissionValue)
.call()
}

private object HttpPostAutowireClient extends autowire.Client[ByteBuffer, Pickler, Pickler] {
override def doCall(req: Request): Future[ByteBuffer] = {
dom.ext.Ajax
Expand Down
32 changes: 32 additions & 0 deletions app/js/shared/src/main/scala/app/common/AnswerBullet.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package app.common

import japgolly.scalajs.react.vdom.html_<^._
import japgolly.scalajs.react.vdom.html_<^.<
import app.flux.stores.quiz.GamepadStore.Arrow
import app.models.quiz.QuizState
import app.models.quiz.QuizState.GeneralQuizSettings.AnswerBulletType
import hydro.flux.react.uielements.Bootstrap
import japgolly.scalajs.react.vdom.html_<^.VdomTag
import japgolly.scalajs.react.vdom.VdomNode

import scala.collection.immutable.Seq

final class AnswerBullet private (character: Char, val arrowIcon: VdomTag) {

def answerIndex: Int = AnswerBullet.all.indexOf(this)

def toVdomNode(implicit quizState: QuizState): VdomNode =
quizState.generalQuizSettings.answerBulletType match {
case AnswerBulletType.Arrows => arrowIcon(^.className := "choice-arrow")
case AnswerBulletType.Characters => s"$character/ "
}

}
object AnswerBullet {
val all: Seq[AnswerBullet] = Seq(
new AnswerBullet('A', Bootstrap.FontAwesomeIcon("chevron-circle-up")),
new AnswerBullet('B', Bootstrap.FontAwesomeIcon("chevron-circle-right")),
new AnswerBullet('C', Bootstrap.FontAwesomeIcon("chevron-circle-down")),
new AnswerBullet('D', Bootstrap.FontAwesomeIcon("chevron-circle-left")),
)
}
27 changes: 0 additions & 27 deletions app/js/shared/src/main/scala/app/flux/ClientApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ object ClientApp {
logExceptions {
implicit val globalModule = new ClientAppModule()

preloadResources(initialDataResponse.quizConfig)
setUpGamepad()

// tell React to render the router in the document body
Expand Down Expand Up @@ -96,32 +95,6 @@ object ClientApp {
}
}

private val preloadedImages: mutable.Buffer[HtmlImage] = mutable.Buffer()
private val preloadedAudios: mutable.Buffer[Audio] = mutable.Buffer()

private def preloadResources(quizConfig: QuizConfig): Unit = {

for {
round <- quizConfig.rounds
question <- round.questions
} {
question match {
case single: Question.Single =>
for (image <- Seq() ++ single.image ++ single.answerImage) {
val htmlImage = new HtmlImage()
htmlImage.asInstanceOf[js.Dynamic].src = s"/quizimages/${image.src}"
preloadedImages.append(htmlImage)
}

for (audioSrc <- single.audioSrc) {
val audio = new Audio(s"/quizaudio/$audioSrc")
preloadedAudios.append(audio)
}
case _ =>
}
}
}

@js.native
@JSGlobal(name = "Image")
class HtmlImage extends js.Object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ final class SoundEffectController(
entityAccess.registerListener(JsEntityAccessListener)

// **************** Public API ****************//
def playNewSubmission(): Unit = playSoundEffect(SoundEffect.NewSubmission, skipPageCheck = true)
def playNewSubmission(): Unit = playSoundEffect(SoundEffect.NewSubmission)
def playRevealingSubmission(correct: Boolean): Unit = {
if (correct) {
playSoundEffect(SoundEffect.CorrectSubmission, skipPageCheck = true)
playSoundEffect(SoundEffect.CorrectSubmission)
} else {
playSoundEffect(SoundEffect.IncorrectSubmission, skipPageCheck = true)
playSoundEffect(SoundEffect.IncorrectSubmission)
}
}
def playTimerRunsOut(): Unit = playSoundEffect(SoundEffect.TimerRunsOut)
Expand All @@ -49,10 +49,9 @@ final class SoundEffectController(
private def playSoundEffect(
soundEffect: SoundEffect,
minTimeBetweenPlays: Option[FiniteDuration] = None,
skipPageCheck: Boolean = false,
): Unit =
logExceptions {
if (skipPageCheck || canPlaySoundEffectsOnThisPage) {
if (canPlaySoundEffectsOnThisPage) {
if (minTimeBetweenPlays.isDefined && (soundsPlaying contains soundEffect)) {
// Skip
} else {
Expand Down
86 changes: 75 additions & 11 deletions app/js/shared/src/main/scala/app/flux/react/app/Layout.scala
Original file line number Diff line number Diff line change
@@ -1,47 +1,74 @@
package app.flux.react.app

import scala.concurrent.duration._
import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue
import hydro.flux.react.ReactVdomUtils.<<
import hydro.flux.react.ReactVdomUtils.^^
import app.flux.react.app.quiz.TeamsList
import app.flux.router.AppPages
import app.flux.stores.quiz.TeamsAndQuizStateStore
import app.flux.ClientApp.HtmlImage
import app.models.quiz.config.QuizConfig
import app.models.quiz.config.QuizConfig.Question
import hydro.flux.react.uielements.SbadminLayout
import hydro.flux.react.HydroReactComponent
import hydro.flux.router.RouterContext
import hydro.jsfacades.Audio
import hydro.jsfacades.Mousetrap
import japgolly.scalajs.react._
import japgolly.scalajs.react.vdom.html_<^._
import japgolly.scalajs.react.Callback

import scala.collection.mutable
import scala.concurrent.Future
import scala.scalajs.js

final class Layout(
implicit sbadminLayout: SbadminLayout,
teamsList: TeamsList,
teamsAndQuizStateStore: TeamsAndQuizStateStore,
) extends HydroReactComponent.Stateless {
quizConfig: QuizConfig,
) extends HydroReactComponent {

// Keep a reference to preloaded media to avoid garbage collection cleaning it up
private val preloadedImages: mutable.Buffer[HtmlImage] = mutable.Buffer()
private val preloadedAudios: mutable.Buffer[Audio] = mutable.Buffer()

// **************** API ****************//
def apply(router: RouterContext)(children: VdomNode*): VdomElement = {
component(Props(router, children.toVector))
}

// **************** Implementation of HydroReactComponent methods **************** //
override protected val statelessConfig =
StatelessComponentConfig(backendConstructor = new Backend(_))
override protected val config = ComponentConfig(backendConstructor = new Backend(_), initialState = State())

// **************** Implementation of HydroReactComponent types ****************//
protected case class State(
boundShortcutsAndPreloadedMedia: Boolean = false,
)
protected case class Props(router: RouterContext, children: Seq[VdomNode])

protected class Backend($ : BackendScope[Props, State]) extends BackendBase($) with DidMount {
protected class Backend($ : BackendScope[Props, State]) extends BackendBase($) {

override def render(props: Props, state: Unit): VdomElement = {
override def render(props: Props, state: State): VdomElement = {
implicit val router = props.router

// This would normally be done via DidMount and DidUpdate hooks, but due to a bug in React or japgolly's
// React wrapper, this isn't possible because duplicate hooks in the same page are mixed together
// (leading to ClassCastExceptions).
maybeScheduleBindShortcutsAndPreloadMedia(state)

sbadminLayout(
title = "Quizmaster",
leftMenu = <.span(),
pageContent = <.div(
^.id := "content-wrapper",
<.div(
^.id := "left-content-wrapper",
teamsList(showScoreEditButtons = router.currentPage.isInstanceOf[AppPages.Master]),
),
<<.ifThen(router.currentPage != AppPages.TeamController) {
<.div(
^.id := "left-content-wrapper",
teamsList(showMasterControls = router.currentPage.isInstanceOf[AppPages.Master]),
)
},
<.div(
^.id := "right-content-wrapper",
props.children.toVdomArray,
Expand All @@ -50,7 +77,22 @@ final class Layout(
)
}

override def didMount(props: Props, state: Unit): Callback = {
private def maybeScheduleBindShortcutsAndPreloadMedia(state: State)(implicit router: RouterContext): Unit = {
if (!state.boundShortcutsAndPreloadedMedia && router.currentPage != AppPages.TeamController) {
js.timers.setTimeout(300.milliseconds) {
val updatedState = $.state.runNow()
if (!updatedState.boundShortcutsAndPreloadedMedia) {
$.modState(_.copy(boundShortcutsAndPreloadedMedia = true)).runNow()
bindShortcuts()
preloadMedia()
}
}
}
}

private def bindShortcuts(): Unit = {
println(" Binding shortcuts...")

def bind(shortcut: String, runnable: () => Unit): Unit = {
Mousetrap.bind(shortcut, e => {
e.preventDefault()
Expand Down Expand Up @@ -79,8 +121,30 @@ final class Layout(
bind(s"$shortkey", () => teamsAndQuizStateStore.updateScore(teamIndex, scoreDiff = +1))
bind(s"shift+$shortkey", () => teamsAndQuizStateStore.updateScore(teamIndex, scoreDiff = -1))
}
}

Callback.empty
private def preloadMedia(): Unit = {
println(" Preloading media...")

for {
round <- quizConfig.rounds
question <- round.questions
} {
question match {
case single: Question.Single =>
for (image <- Seq() ++ single.image ++ single.answerImage) {
val htmlImage = new HtmlImage()
htmlImage.asInstanceOf[js.Dynamic].src = s"/quizimages/${image.src}"
preloadedImages.append(htmlImage)
}

for (audioSrc <- single.audioSrc) {
val audio = new Audio(s"/quizaudio/$audioSrc")
preloadedAudios.append(audio)
}
case _ =>
}
}
}
}
}
4 changes: 4 additions & 0 deletions app/js/shared/src/main/scala/app/flux/react/app/Module.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.flux.react.app

import app.api.ScalaJsApi.GetInitialDataResponse
import app.api.ScalaJsApiClient
import app.flux.controllers.SoundEffectController
import app.flux.react.app.quiz.GamepadSetupView
import app.flux.react.app.quiz.GeneralQuizSettings
Expand All @@ -13,6 +14,7 @@ import app.flux.react.app.quiz.QuizProgressIndicator
import app.flux.react.app.quiz.QuizSettingsPanels
import app.flux.react.app.quiz.QuizView
import app.flux.react.app.quiz.SyncedTimerBar
import app.flux.react.app.quiz.TeamControllerView
import app.flux.react.app.quiz.TeamEditor
import app.flux.react.app.quiz.TeamsList
import app.flux.stores._
Expand All @@ -39,6 +41,7 @@ final class Module(
teamInputStore: TeamInputStore,
dispatcher: Dispatcher,
clock: Clock,
scalaJsApiClient: ScalaJsApiClient,
quizConfig: QuizConfig,
soundEffectController: SoundEffectController,
getInitialDataResponse: GetInitialDataResponse,
Expand All @@ -61,6 +64,7 @@ final class Module(
implicit private val quizSettingsPanels: QuizSettingsPanels = new QuizSettingsPanels()

implicit val layout: Layout = new Layout
implicit val teamController: TeamControllerView = new TeamControllerView()
implicit val quizView: QuizView = new QuizView()
implicit val masterView: MasterView = new MasterView()
implicit val gamepadSetupView: GamepadSetupView = new GamepadSetupView()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.flux.react.app.quiz

import app.common.AnswerBullet
import app.flux.stores.quiz.GamepadStore.Arrow
import hydro.flux.react.ReactVdomUtils.<<
import hydro.flux.react.ReactVdomUtils.^^
Expand Down Expand Up @@ -101,10 +102,10 @@ final class GamepadSetupView(
Bootstrap.FontAwesomeIcon("gamepad"),
gamepadState.arrowPressed match {
case None =>
Arrow.Up.icon(
AnswerBullet.all.head.arrowIcon(
^.style := js.Dictionary("color" -> "white"),
)
case Some(arrow) => arrow.icon
case Some(arrow) => AnswerBullet.all(arrow.answerIndex).arrowIcon
},
Bootstrap.FontAwesomeIcon("circle")(
^^.ifThen(!gamepadState.otherButtonPressed) {
Expand Down
Loading

0 comments on commit 0824129

Please sign in to comment.