diff --git a/.gitignore b/.gitignore index a0eb30c..f940cad 100644 --- a/.gitignore +++ b/.gitignore @@ -325,3 +325,11 @@ __pycache__/ ### VisualStudio Patch ### build/ + + +### Meteor +node_modules/ +.meteor/local +npm-debug.log +typings +.idea diff --git a/.meteor/.finished-upgraders b/.meteor/.finished-upgraders new file mode 100644 index 0000000..a541808 --- /dev/null +++ b/.meteor/.finished-upgraders @@ -0,0 +1,16 @@ +# This file contains information which helps Meteor properly upgrade your +# app when you run 'meteor update'. You should check it into version control +# with your project. + +notices-for-0.9.0 +notices-for-0.9.1 +0.9.4-platform-file +notices-for-facebook-graph-api-2 +1.2.0-standard-minifiers-package +1.2.0-meteor-platform-split +1.2.0-cordova-changes +1.2.0-breaking-changes +1.3.0-split-minifiers-package +1.3.5-remove-old-dev-bundle-link +1.4.0-remove-old-dev-bundle-link +1.4.1-add-shell-server-package diff --git a/.meteor/.gitignore b/.meteor/.gitignore new file mode 100644 index 0000000..501f92e --- /dev/null +++ b/.meteor/.gitignore @@ -0,0 +1,2 @@ +dev_bundle +local diff --git a/.meteor/.id b/.meteor/.id new file mode 100644 index 0000000..0993940 --- /dev/null +++ b/.meteor/.id @@ -0,0 +1,7 @@ +# This file contains a token that is unique to your project. +# Check it into your repository along with the rest of this directory. +# It can be used for purposes such as: +# - ensuring you don't accidentally deploy one app on top of another +# - providing package authors with aggregated statistics + +a4kqps1d5y31214lew0l diff --git a/.meteor/packages b/.meteor/packages new file mode 100644 index 0000000..a4e0903 --- /dev/null +++ b/.meteor/packages @@ -0,0 +1,24 @@ +# Meteor packages used by this project, one per line. +# Check this file (and the other files in this directory) into your repository. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + +meteor-base@1.0.4 # Packages every Meteor app needs to have +mobile-experience@1.0.4 # Packages for a great mobile UX +mongo@1.1.14 # The database Meteor supports right now +reactive-var@1.0.11 # Reactive variable for tracker +tracker@1.1.1 # Meteor's client-side reactive programming library + +standard-minifier-css@1.3.2 # CSS minifier run for production mode +standard-minifier-js@1.2.1 # JS minifier run for production mode +es5-shim@4.6.15 # ECMAScript 5 compatibility for older browsers. + +autopublish@1.0.7 # Publish all data to the clients (for prototyping) +insecure@1.0.7 # Allow all DB writes from clients (for prototyping) +angular2-compilers +practicalmeteor:mocha +xolvio:cleaner +hwillson:stub-collections +dispatch:mocha-phantomjs +shell-server@0.2.1 diff --git a/.meteor/platforms b/.meteor/platforms new file mode 100644 index 0000000..efeba1b --- /dev/null +++ b/.meteor/platforms @@ -0,0 +1,2 @@ +server +browser diff --git a/.meteor/release b/.meteor/release new file mode 100644 index 0000000..61f6c67 --- /dev/null +++ b/.meteor/release @@ -0,0 +1 @@ +METEOR@1.4.2.3 diff --git a/.meteor/versions b/.meteor/versions new file mode 100644 index 0000000..f9f8f8c --- /dev/null +++ b/.meteor/versions @@ -0,0 +1,90 @@ +allow-deny@1.0.5 +angular2-compilers@0.6.6 +autopublish@1.0.7 +autoupdate@1.3.12 +babel-compiler@6.13.0 +babel-runtime@1.0.1 +barbatus:caching-compiler@1.1.9 +barbatus:css-compiler@0.4.1 +barbatus:scss-compiler@3.8.3 +barbatus:typescript@0.6.2_3 +barbatus:typescript-compiler@0.9.2_1 +barbatus:typescript-runtime@1.0.0 +base64@1.0.10 +binary-heap@1.0.10 +blaze@2.2.1 +blaze-tools@1.0.10 +boilerplate-generator@1.0.11 +caching-compiler@1.1.9 +caching-html-compiler@1.0.7 +callback-hook@1.0.10 +check@1.2.4 +coffeescript@1.11.1_4 +ddp@1.2.5 +ddp-client@1.3.2 +ddp-common@1.2.8 +ddp-server@1.3.12 +deps@1.0.12 +diff-sequence@1.0.7 +dispatch:mocha-phantomjs@0.1.9 +dispatch:phantomjs-tests@0.0.7 +ecmascript@0.6.1 +ecmascript-runtime@0.3.15 +ejson@1.0.13 +es5-shim@4.6.15 +fastclick@1.0.13 +geojson-utils@1.0.10 +hot-code-push@1.0.4 +html-tools@1.0.11 +htmljs@1.0.11 +http@1.2.10 +hwillson:stub-collections@1.0.3 +id-map@1.0.9 +insecure@1.0.7 +jquery@1.11.10 +launch-screen@1.1.0 +livedata@1.0.18 +logging@1.1.16 +meteor@1.6.0 +meteor-base@1.0.4 +minifier-css@1.2.15 +minifier-js@1.2.15 +minimongo@1.0.19 +mobile-experience@1.0.4 +mobile-status-bar@1.0.13 +modules@0.7.7 +modules-runtime@0.7.8 +mongo@1.1.14 +mongo-id@1.0.6 +npm-mongo@2.2.16_1 +observe-sequence@1.0.14 +ordered-dict@1.0.9 +practicalmeteor:chai@2.1.0_1 +practicalmeteor:loglevel@1.2.0_2 +practicalmeteor:mocha@2.4.5_6 +practicalmeteor:mocha-core@1.0.1 +practicalmeteor:sinon@1.14.1_2 +promise@0.8.8 +random@1.0.10 +reactive-var@1.0.11 +reload@1.1.11 +retry@1.0.9 +routepolicy@1.0.12 +shell-server@0.2.1 +spacebars@1.0.13 +spacebars-compiler@1.0.13 +standard-minifier-css@1.3.2 +standard-minifier-js@1.2.1 +templating@1.2.15 +templating-compiler@1.2.15 +templating-runtime@1.2.15 +templating-tools@1.0.5 +tmeasday:test-reporter-helpers@0.2.1 +tracker@1.1.1 +ui@1.0.12 +underscore@1.0.10 +urigo:static-html-compiler@0.1.8 +url@1.0.11 +webapp@1.3.12 +webapp-hashing@1.0.9 +xolvio:cleaner@0.3.1 diff --git a/LICENSE b/LICENSE index d86588f..2334c4a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ -MIT License +The MIT License (MIT) -Copyright (c) 2016 Dominik Prikril +Copyright (c) 2016 Uri Goldshtein Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 77ef8da..dc67a38 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,81 @@ -# ro-inf-sa -Software Architektur - Meteor +# Angular2-Meteor Boilerplate + +[![bitHound Overall Score](https://www.bithound.io/github/bsliran/angular2-meteor-base/badges/score.svg)](https://www.bithound.io/github/bsliran/angular2-meteor-base) [![bitHound Dependencies](https://www.bithound.io/github/bsliran/angular2-meteor-base/badges/dependencies.svg)](https://www.bithound.io/github/bsliran/angular2-meteor-base/master/dependencies/npm) + + +## Usage + +Since Meteor v1.4 you can use one command to create a working Angular2 app based on this boilerplate: + +``` +meteor create --example angular2-boilerplate +``` + +## NPM Scripts + +This boilerplate comes with predefined NPM scripts, defined in `package.json`: + +- `$ npm run start` - Run the Meteor application. +- `$ npm run start:prod` - Run the Meteor application in production mode. +- `$ npm run build` - Creates a Meteor build version under `./build/` directory. +- `$ npm run clear` - Resets Meteor's cache and clears the MongoDB collections. +- `$ npm run meteor:update` - Updates Meteor's version and it's dependencies. +- `$ npm run test` - Executes Meteor in test mode with Mocha. +- `$ npm run test:ci` - Executes Meteor in test mode with Mocha for CI (run once). + +## Boilerplate Contents + +This boilerplate contains the basics that requires to quick start with Angular2-Meteor application. + +This package contains: + +- TypeScript support (with `@types`) and Angular 2 compilers for Meteor +- Angular2-Meteor +- Angular 2 (core, common, compiler, platform, router, forms) +- SASS, LESS, CSS support (Also support styles encapsulation for Angular 2) +- Testing framework with Mocha and Chai +- [Meteor-RxJS](http://angular-meteor.com/meteor-rxjs/) support and usage + +This application also contains demo code: + +- Main Component (`/client/app.component`) +- Demo Child Component (`/client/imports/demo/demo.component`) +- Demo Service (`/client/imports/demo/demo-data.service`) +- Demo Mongo Collection (`/both/demo.collection.ts`) with a TypeScript interface as model. + +The Main component loads the child component, which uses the demo service that gets it's data from the demo collection. + +### Folder Structure + +The folder structure is a mix between [Angular 2 recommendation](https://johnpapa.net/angular-2-styles/) and [Meteor 1.3 recommendation](https://guide.meteor.com/structure.html). + +### Client + +The `client` folder contains single TypeScript (`.ts`) file which is the main file (`/client/app.component.ts`), and bootstrap's the Angular 2 application. + +The main component uses HTML template and SASS file. + +The `index.html` file is the main HTML which loads the application by using the main component selector (``). + +All the other client files are under `client/imports` and organized by the context of the components (in our example, the context is `demo`). + + +### Server + +The `server` folder contain single TypeScript (`.ts`) file which is the main file (`/server/main.ts`), and creates the main server instance, and the starts it. + +All other server files should be located under `/server/imports`. + +### Common + +Example for common files in our app, is the MongoDB collection we create - it located under `/both/demo-collection.ts` and it can be imported from both client and server code. + +### Testing + +The testing environment in this boilerplate based on [Meteor recommendation](https://guide.meteor.com/testing.html), and uses Mocha as testing framework along with Chai for assertion. + +There is a main test file that initialize Angular 2 tests library, it located under `/client/init.test.ts`. + +All other test files are located near the component/service it tests, with the `.test.ts` extension. + +The `DemoComponent` contains example for Angular 2 tests for Component, and in the server side there is an example for testing Meteor collections and stub data. diff --git a/both/collections/game.collection.ts b/both/collections/game.collection.ts new file mode 100644 index 0000000..001cb7a --- /dev/null +++ b/both/collections/game.collection.ts @@ -0,0 +1,4 @@ +import { MongoObservable } from "meteor-rxjs"; +import {Game} from "../models/game.model"; + +export const GameCollection = new MongoObservable.Collection("game-collection"); \ No newline at end of file diff --git a/both/collections/gameResult.collection.ts b/both/collections/gameResult.collection.ts new file mode 100644 index 0000000..4b6780c --- /dev/null +++ b/both/collections/gameResult.collection.ts @@ -0,0 +1,4 @@ +import { MongoObservable } from "meteor-rxjs"; +import {GameResult} from "../models/gameResult.model"; + +export const GameResultCollection = new MongoObservable.Collection("gameResult-collection"); \ No newline at end of file diff --git a/both/collections/player.collection.ts b/both/collections/player.collection.ts new file mode 100644 index 0000000..39dbc43 --- /dev/null +++ b/both/collections/player.collection.ts @@ -0,0 +1,4 @@ +import { MongoObservable } from "meteor-rxjs"; +import {Player} from "../models/player.model"; + +export const PlayerCollection = new MongoObservable.Collection("player-collection"); \ No newline at end of file diff --git a/both/collections/quiz.collection.ts b/both/collections/quiz.collection.ts new file mode 100644 index 0000000..ba35c13 --- /dev/null +++ b/both/collections/quiz.collection.ts @@ -0,0 +1,4 @@ +import { MongoObservable } from "meteor-rxjs"; +import {Quiz} from "../models/quiz.model"; + +export const QuizCollection = new MongoObservable.Collection("quiz-collection"); \ No newline at end of file diff --git a/both/models/answer.model.ts b/both/models/answer.model.ts new file mode 100644 index 0000000..a8b4d0f --- /dev/null +++ b/both/models/answer.model.ts @@ -0,0 +1,5 @@ +export class Answer { + _id: string; + answer: string; + right: boolean; +} \ No newline at end of file diff --git a/both/models/game.model.ts b/both/models/game.model.ts new file mode 100644 index 0000000..aa9ffc5 --- /dev/null +++ b/both/models/game.model.ts @@ -0,0 +1,17 @@ +import {Player} from "./player.model"; +import {Question} from "./question.model"; +import {GivenAnswer} from "./givenAnswers.model"; + +export class Game { + _id: string; + quizId: string; + gameResultId : string; + gameNumber: string; + running: boolean; + showResult: boolean; + players: Player[]; + currentQuestion : Question; + currentIndex : number; + timer : number; + questionStarted : number; +} diff --git a/both/models/gameResult.model.ts b/both/models/gameResult.model.ts new file mode 100644 index 0000000..3dcb5bb --- /dev/null +++ b/both/models/gameResult.model.ts @@ -0,0 +1,6 @@ +import {GivenAnswer} from "./givenAnswers.model"; +export class GameResult{ + _id : string; + quizId : string; + givenAnswers : {[questionNo : number] : GivenAnswer[]} = {}; +} diff --git a/both/models/givenAnswers.model.ts b/both/models/givenAnswers.model.ts new file mode 100644 index 0000000..da23765 --- /dev/null +++ b/both/models/givenAnswers.model.ts @@ -0,0 +1,6 @@ + +export class GivenAnswer { + playerId : string; + givenAnswer : number; + timeAnswered : number; +} \ No newline at end of file diff --git a/both/models/player.model.ts b/both/models/player.model.ts new file mode 100644 index 0000000..1bff7d9 --- /dev/null +++ b/both/models/player.model.ts @@ -0,0 +1,7 @@ +export class Player { + _id: string; + gameId: string; + name: string; + playing: boolean; + score: number; +} diff --git a/both/models/question.model.ts b/both/models/question.model.ts new file mode 100644 index 0000000..9e92cfc --- /dev/null +++ b/both/models/question.model.ts @@ -0,0 +1,6 @@ +import {Answer} from "./answer.model"; + +export class Question { + question: string; + answers: Answer[]; +} \ No newline at end of file diff --git a/both/models/quiz.model.ts b/both/models/quiz.model.ts new file mode 100644 index 0000000..b501882 --- /dev/null +++ b/both/models/quiz.model.ts @@ -0,0 +1,7 @@ +import {Question} from "./question.model"; + +export class Quiz { + _id: string; + name: string; + questions: Question[]; +} \ No newline at end of file diff --git a/client/imports/app/app.component.html b/client/imports/app/app.component.html new file mode 100644 index 0000000..b82c58c --- /dev/null +++ b/client/imports/app/app.component.html @@ -0,0 +1,27 @@ + + + + + + + + + + +
+ +
+ + diff --git a/client/imports/app/app.component.scss b/client/imports/app/app.component.scss new file mode 100644 index 0000000..05f7e51 --- /dev/null +++ b/client/imports/app/app.component.scss @@ -0,0 +1,88 @@ +.toolbar-title { + text-decoration: none; + color: white; +} + +md-toolbar { + box-shadow: 0 2px 5px 0 rgba(0,0,0,0.26); +} + +.app-content { + padding: 20px; +} + +.app-content md-card { + margin: 20px; +} + +.app-sidenav { + padding: 10px; + min-width: 100px; +} + +.app-content md-checkbox { + margin: 10px; +} + +.app-toolbar-filler { + flex: 1 1 auto; +} + +.app-toolbar-menu { + padding: 0 14px 0 14px; + color: white; +} + +.app-icon-button { + box-shadow: none; + user-select: none; + background: none; + border: none; + cursor: pointer; + filter: none; + font-weight: normal; + height: auto; + line-height: inherit; + margin: 0; + min-width: 0; + padding: 0; + text-align: left; + text-decoration: none; +} + +.app-action { + display: inline-block; + position: fixed; + bottom: 20px; + right: 20px; +} + +.app-spinner { + height: 30px; + width: 30px; + display: inline-block; +} + +.app-input-icon { + font-size: 16px; +} + +.app-list { + border: 1px solid rgba(0,0,0,0.12); + width: 350px; + margin: 20px; +} + +.app-progress { + margin: 20px; +} + +ul { + list-style-type: none; + margin: 0; + padding: 0; +} + +li { + display: inline; +} \ No newline at end of file diff --git a/client/imports/app/app.component.ts b/client/imports/app/app.component.ts new file mode 100644 index 0000000..34de543 --- /dev/null +++ b/client/imports/app/app.component.ts @@ -0,0 +1,15 @@ +import { Component } from "@angular/core"; +import template from "./app.component.html"; +import style from "./app.component.scss"; + +@Component({ + selector: "app", + template, + styles: [style] +}) +export class AppComponent { + constructor() { + } + + +} diff --git a/client/imports/app/app.module.ts b/client/imports/app/app.module.ts new file mode 100644 index 0000000..a544dc9 --- /dev/null +++ b/client/imports/app/app.module.ts @@ -0,0 +1,43 @@ +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { AppComponent } from './app.component'; +import { HomeComponent } from './home/home.component'; +import { RouterModule } from '@angular/router'; +import { MaterialModule } from '@angular/material'; +import { routes } from './app.routes'; +import { QuizMasterModule } from "./quizMaster/quizMaster.module"; +import {CompetitorModule} from "./competitor/competitor.module"; +import { ChartsModule } from 'ng2-charts'; + +@NgModule({ + // Components, Pipes, Directive + declarations: [ + AppComponent, + HomeComponent + ], + // Entry Components + entryComponents: [ + AppComponent + ], + // Providers + providers: [], + // Modules + imports: [ + BrowserModule, + FormsModule, + ReactiveFormsModule, + MaterialModule.forRoot(), + RouterModule.forRoot(routes), + QuizMasterModule, + CompetitorModule, + ChartsModule + ], + // Main Component + bootstrap: [AppComponent] +}) +export class AppModule { + constructor() { + + } +} diff --git a/client/imports/app/app.routes.ts b/client/imports/app/app.routes.ts new file mode 100644 index 0000000..5208b6a --- /dev/null +++ b/client/imports/app/app.routes.ts @@ -0,0 +1,7 @@ +import { Route } from '@angular/router'; +import { HomeComponent } from './home/home.component'; + +export const routes: Route[] = [ + { path: '', component: HomeComponent }, + { path: '**', redirectTo: '', pathMatch: 'full' } +]; diff --git a/client/imports/app/competitor/competitor.module.ts b/client/imports/app/competitor/competitor.module.ts new file mode 100644 index 0000000..3048167 --- /dev/null +++ b/client/imports/app/competitor/competitor.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from '@angular/core'; +import { RouterModule} from '@angular/router'; + +import { StandbyComponent } from './standby/standby.component'; +import { QuestionComponent } from './question/question.component'; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { MaterialModule } from "@angular/material"; +import {BrowserModule} from "@angular/platform-browser"; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: 'competitor/standby', component: StandbyComponent }, + { path: 'competitor/question/:playerId', component: QuestionComponent } + ]), + FormsModule, + ReactiveFormsModule, + MaterialModule.forRoot(), + BrowserModule + ], + exports: [], + declarations: [ + StandbyComponent, + QuestionComponent + ], + providers: [], +}) +export class CompetitorModule { } diff --git a/client/imports/app/competitor/question/question.component.html b/client/imports/app/competitor/question/question.component.html new file mode 100644 index 0000000..3ae84be --- /dev/null +++ b/client/imports/app/competitor/question/question.component.html @@ -0,0 +1,17 @@ +

Your Score: {{score}}

+
+
{{question}}
+
+
+ + +
+
+ + +
+
+
+
+

Waiting for the quiz master to continue with the next question...

+
\ No newline at end of file diff --git a/client/imports/app/competitor/question/question.component.scss b/client/imports/app/competitor/question/question.component.scss new file mode 100644 index 0000000..34669ec --- /dev/null +++ b/client/imports/app/competitor/question/question.component.scss @@ -0,0 +1,26 @@ +.quiz { + width:90%; + left: 50%; + top: 50%; + margin:auto; + + max-width:100%; + max-height:100%; + overflow:auto; +} +.question { + border-style: solid; + padding: 30px; +} +.row { + display: flex; +} +.answer { + flex: 1; + width: auto; + margin: 10px; + padding: 20px; +} +.selected { + background-color: yellow; +} \ No newline at end of file diff --git a/client/imports/app/competitor/question/question.component.ts b/client/imports/app/competitor/question/question.component.ts new file mode 100644 index 0000000..5cd5116 --- /dev/null +++ b/client/imports/app/competitor/question/question.component.ts @@ -0,0 +1,138 @@ +import { Component, OnInit } from '@angular/core'; + +import template from './question.component.html'; +import style from "./question.component.scss"; +import {Game} from "../../../../../both/models/game.model"; +import {ActivatedRoute} from "@angular/router"; +import {Subscription} from 'rxjs/Subscription'; +import {MeteorObservable} from "meteor-rxjs"; +import {Question} from "../../../../../both/models/question.model"; +import {GameCollection} from "../../../../../both/collections/game.collection"; +import {Player} from "../../../../../both/models/player.model"; +import {PlayerCollection} from "../../../../../both/collections/player.collection"; + +@Component({ + selector: 'question', + template, + styles: [style] +}) +export class QuestionComponent implements OnInit { + + private gameSubscription : Subscription; + private routeSubscription : Subscription; + private playerSubscription : Subscription; + + game : Game; + player : Player; + playerId : string; + foundGame : boolean; + quizId : String; + answerGiven: boolean = false; + showResult: boolean = false; + selectedAnswer : number; + + //Question properties for View + question : string; + answer1 : string; + answer2 : string; + answer3 : string; + answer4 : string; + score : number; + + + constructor(private activatedRoute: ActivatedRoute) { } + ngOnInit() { + this.routeSubscription = this.activatedRoute.params.subscribe( + (param : any) => { + this.playerId = param['playerId']; + this.getPlayerFromServer(this.playerId); + }); + } + + ngOnDestroy() { + this.routeSubscription.unsubscribe(); + this.gameSubscription.unsubscribe(); + } + + answerQuestion(answer : number) : void { + if (this.answerGiven) { + return; + } + + if(answer < 1 || answer > 4) { + alert("Answer out of range"); + throw new RangeError("Given Answer: " + answer); + } + MeteorObservable.call('answerFromPlayer', this.game._id, + this.playerId, + answer).subscribe((success : boolean) => { + if(success) { + this.answerGiven = true; + this.selectedAnswer = answer; + } + console.log(success); + }); + + console.log(this.playerId); + + } + + private getPlayerFromServer(playerId : string) : void { + MeteorObservable.call('fetchPlayerById', playerId).subscribe((player : Player) => { + this.player = player; + //next async requests: + this.score = player.score; + this.getGameFromServer(player.gameId); + this.subscribeGame(player.gameId); + this.subscribePlayer(player._id); + }, (error) => { + alert(`Error: ${error}`); + throw new Error(error); + }); + } + + private getGameFromServer(gameId : string) : void { + MeteorObservable.call('fetchGameById', gameId).subscribe((game : Game) => { + this.game = game; + //next async requests: + }, (error) => { + alert(`Error: ${error}`); + throw new Error(error); + }); + } + + private subscribeGame(gameId : string) { + // https://github.com/Urigo/meteor-rxjs + this.gameSubscription = GameCollection.find(gameId) + .map(games => games[0]) // game => games[0] picks first game found by _id, should only find one game + .subscribe(game => this.gameChanged(game)); + } + + private subscribePlayer(playerId : string) { + this.playerSubscription = PlayerCollection.find(playerId) + .map(player => player[0]) + .subscribe(player => this.getNewScore(player.score)); + } + + private gameChanged(game : Game) { + if(game != undefined && game != null) { + this.showResult = game.showResult; + if(!this.showResult && game.currentQuestion != undefined){ + console.log(game); + this.question = game.currentQuestion.question; + this.answer1 = game.currentQuestion.answers[0].answer; + this.answer2 = game.currentQuestion.answers[1].answer; + this.answer3 = game.currentQuestion.answers[2].answer; + this.answer4 = game.currentQuestion.answers[3].answer; + + this.answerGiven = false; + this.selectedAnswer = null; + } + + } + } + + private getNewScore(score: number) { + this.score = score; + } +} \ No newline at end of file diff --git a/client/imports/app/competitor/standby/standby.component.html b/client/imports/app/competitor/standby/standby.component.html new file mode 100644 index 0000000..d60876e --- /dev/null +++ b/client/imports/app/competitor/standby/standby.component.html @@ -0,0 +1,19 @@ +
+

Please enter the Quizza number and your player name

+
+ + + + + + + +
+ +
+ +

Debug: foundGame = {{foundGame}}

+ +
+

Waiting for Quizmaster...

+
\ No newline at end of file diff --git a/client/imports/app/competitor/standby/standby.component.scss b/client/imports/app/competitor/standby/standby.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/client/imports/app/competitor/standby/standby.component.ts b/client/imports/app/competitor/standby/standby.component.ts new file mode 100644 index 0000000..621ad9a --- /dev/null +++ b/client/imports/app/competitor/standby/standby.component.ts @@ -0,0 +1,99 @@ +import { Component, OnInit } from '@angular/core'; + +import template from './standby.component.html'; +import style from "./standby.component.scss"; +import {Subscription} from 'rxjs/Subscription'; +import {FormGroup, FormBuilder, Validators} from "@angular/forms"; +import {MeteorObservable} from "meteor-rxjs"; +import {Game} from "../../../../../both/models/game.model"; +import {Player} from "../../../../../both/models/player.model"; +import {Router} from "@angular/router"; +import {GameCollection} from "../../../../../both/collections/game.collection"; + +@Component({ + selector: 'standby', + template, + styles: [style] +}) +export class StandbyComponent implements OnInit { + private quizStartSubscription : Subscription; + + joinForm: FormGroup; + foundGame: boolean; + playerId: string; + + constructor( + private formBuilder: FormBuilder, + private router : Router + ) { } + + ngOnInit() { + this.joinForm = this.formBuilder.group({ + gameNumber: ['', Validators.required], + name: ['', Validators.required] + }); + } + + ngOnDestroy() { + this.quizStartSubscription.unsubscribe(); + } + + join(formValues: Object) { + if (this.joinForm.valid) { + let gameNumber = formValues["gameNumber"]; + let name = formValues["name"]; + MeteorObservable.call('fetchNotRunningGameByNumber', gameNumber).subscribe((game : Game) => { + if (game == null) { + this.foundGame = false; + } else { + this.foundGame = true; + this.createPlayerForGame(game._id, name); + } + + }, (error) => { + alert(`Error: ${error}`); + }); + } + } + + private createPlayerForGame(gameId: string, name: string) { + MeteorObservable.call('addPlayer', gameId, name).subscribe((player : Player) => { + if (player != null) { + //success + this.addPlayerToGame(gameId, player); + } + + }, (error) => { + alert(`Error: ${error}`); + }); + } + + private addPlayerToGame(gameId: string, player: Player) { + MeteorObservable.call('joinGame', gameId, player).subscribe((success : boolean) => { + if (success) { + //player added + this.playerId = player._id; + + this.subscribeGame(gameId); + } + + }, (error) => { + alert(`Error: ${error}`); + }); + } + + private subscribeGame(gameId: string) { + this.quizStartSubscription = GameCollection.find(gameId) + .map(games => games[0]) // game => games[0] picks first game found by _id, should only find one game + .subscribe(game => this.quizStart(game)); + } + + private quizStart(game : Game){ + console.log("Quiz start?"); + console.log(game); + if(game != undefined && game.running) { + console.log("Quiz start!"); + this.router.navigateByUrl('competitor/question/'+this.playerId); + } + } +} \ No newline at end of file diff --git a/client/imports/app/home/home.component.html b/client/imports/app/home/home.component.html new file mode 100644 index 0000000..135c002 --- /dev/null +++ b/client/imports/app/home/home.component.html @@ -0,0 +1,8 @@ +
+

Welcome to Quizza!

+



+ +
\ No newline at end of file diff --git a/client/imports/app/home/home.component.scss b/client/imports/app/home/home.component.scss new file mode 100644 index 0000000..ed810a2 --- /dev/null +++ b/client/imports/app/home/home.component.scss @@ -0,0 +1,24 @@ +.body { + width:60%; + left: 50%; + top: 50%; + margin:auto; + + max-width:100%; + max-height:100%; + overflow:auto; +} +.body h1 { + margin: auto; + text-align: center; +} +container { + margin:auto; + display: flex; +} +.container a { + width: auto; + font-size: 20pt; + margin: 10pt; + padding: 5pt; +} \ No newline at end of file diff --git a/client/imports/app/home/home.component.ts b/client/imports/app/home/home.component.ts new file mode 100644 index 0000000..7098f55 --- /dev/null +++ b/client/imports/app/home/home.component.ts @@ -0,0 +1,14 @@ +import { Component, OnInit } from '@angular/core'; +import template from './home.component.html'; +import style from './home.component.scss'; + +@Component({ + template, + styles: [style] +}) +export class HomeComponent implements OnInit { + constructor() { } + + ngOnInit() { } + +} diff --git a/client/imports/app/index.ts b/client/imports/app/index.ts new file mode 100644 index 0000000..f363a66 --- /dev/null +++ b/client/imports/app/index.ts @@ -0,0 +1,2 @@ +export * from "./app.component"; +export * from "./app.module"; diff --git a/client/imports/app/quizMaster/create/create.component.html b/client/imports/app/quizMaster/create/create.component.html new file mode 100644 index 0000000..4295721 --- /dev/null +++ b/client/imports/app/quizMaster/create/create.component.html @@ -0,0 +1,34 @@ +
+ + Create a new Quiz + + + + Quiz name is required (minimum 4 characters). + + + + +

Questions

+
+
+
+ +
+
+
+ + is correct +
+
+
+
+
+
+
+ + + + +
+
diff --git a/client/imports/app/quizMaster/create/create.component.scss b/client/imports/app/quizMaster/create/create.component.scss new file mode 100644 index 0000000..8b44c5e --- /dev/null +++ b/client/imports/app/quizMaster/create/create.component.scss @@ -0,0 +1,21 @@ +.quizForm { + md-input { + width: 100%; + } + + .answer { + md-input { + width: 80%; + } + md-checkbox { + width: 20%; + } + } + .question { + border: dashed bisque 3px; + margin: 8px 0; + padding: 8px; + } + + padding-bottom: 150px; +} \ No newline at end of file diff --git a/client/imports/app/quizMaster/create/create.component.ts b/client/imports/app/quizMaster/create/create.component.ts new file mode 100644 index 0000000..322eb94 --- /dev/null +++ b/client/imports/app/quizMaster/create/create.component.ts @@ -0,0 +1,125 @@ +import { Component, OnInit } from '@angular/core'; +import { + FormBuilder, + FormGroup, + Validators, FormArray, FormControl +} from '@angular/forms'; +import { Quiz } from '../../../../../both/models/quiz.model'; +import template from './create.component.html'; +import style from "./create.component.scss"; +import {MeteorObservable} from "meteor-rxjs"; +import {Router} from "@angular/router"; + +@Component({ + selector: 'create', + template, + styles: [style] +}) +export class CreateComponent implements OnInit { + + quizForm: FormGroup; + + constructor(private formBuilder: FormBuilder, private router : Router) { } + + ngOnInit() { + this.quizForm = this.formBuilder.group({ + name: ['', Validators.required], + questions: this.formBuilder.array([ + this.initQuestionFormGroup() + ]) + }); + } + + /** + * Creates a FormGroup for a question with answers + * @returns {FormGroup} + */ + initQuestionFormGroup() { + return this.formBuilder.group({ + question: ['', Validators.required], + answers: this.initAnswerFormGroup() + }, { + validator: this.validateAnswer + }); + } + + validateAnswer(group: FormControl) { + let valid = false; + + for(let answer of group.get('answers').value) { + if (answer['right']) { + valid = true; + } + } + + if (valid) { + return null; + } else { + return { + validateAnswer: { + valid: false + } + } + } + } + + /** + * Creates a FormArray with four answers + * @returns {FormArray} + */ + initAnswerFormGroup(): FormArray { + let answers: FormGroup[] = []; + for(let i = 0; i < 4; i++) { + answers.push( + this.formBuilder.group({ + answer: ['', Validators.required], + right: [false, Validators.required] + }) + ) + } + return this.formBuilder.array(answers); + } + + /** + * Adds more questions to the form + */ + addQuestion() { + const control = this.quizForm.controls['questions']; + control.push(this.initQuestionFormGroup()); + } + + /** + * Call it before you check the right answer + * It will disable all other answers + * @param questionIndex the index of the question in the array of Questions + */ + checkRightAnswer(questionIndex: number) { + this.uncheckAll(questionIndex); + } + + /** + * Sava the Quiz + * @param model + */ + save(model: Quiz) { + MeteorObservable.call('saveQuiz', model).subscribe((quiz : Quiz) => { + // Success Redirect ... + this.router.navigateByUrl('master/list'); + }, (error) => { + alert(`Error: ${error}`); + }); + } + + /** + * Unchecks all answer checkboxes from the given question index + * @param questionIndex the index of the question in the array of Questions + */ + private uncheckAll(questionIndex: number) { + this.quizForm.get('questions'); + let answers = this.quizForm.get('questions').controls[questionIndex].get('answers').controls; + + for(let answer of answers) { + answer.get('right').setValue(false); + } + } +} \ No newline at end of file diff --git a/client/imports/app/quizMaster/list/list.component.html b/client/imports/app/quizMaster/list/list.component.html new file mode 100644 index 0000000..57b8077 --- /dev/null +++ b/client/imports/app/quizMaster/list/list.component.html @@ -0,0 +1,21 @@ +

Quizzas

+ + + + + + + + + + + + + + +
NameIDQuestions
{{q.name}}{{q._id}}{{q.questions.length}}Start Quizza
+
+Create a new Quizza +
+
+ diff --git a/client/imports/app/quizMaster/list/list.component.scss b/client/imports/app/quizMaster/list/list.component.scss new file mode 100644 index 0000000..435a533 --- /dev/null +++ b/client/imports/app/quizMaster/list/list.component.scss @@ -0,0 +1,21 @@ +table { + width: 100%; + border-collapse: collapse; +} + +th { + height: 50px; +} + +th, td { + padding: 15px; + text-align: left; +} + +th, td { + border-bottom: 1px solid #ddd; +} + +tr:hover { + background-color: #d2d2d2; +} \ No newline at end of file diff --git a/client/imports/app/quizMaster/list/list.component.ts b/client/imports/app/quizMaster/list/list.component.ts new file mode 100644 index 0000000..82ec331 --- /dev/null +++ b/client/imports/app/quizMaster/list/list.component.ts @@ -0,0 +1,21 @@ +import { Component, OnInit } from '@angular/core'; +import { QuizCollection } from '../../../../../both/collections/quiz.collection'; +import template from './list.component.html'; +import style from "./list.component.scss"; +import { Observable } from "rxjs/Observable"; + +@Component({ + template, + styles: [style] +}) +export class ListComponent implements OnInit { + + quizList: Observable; + + constructor() { + this.quizList = QuizCollection.find({}).zone(); + } + + ngOnInit() { + } +} \ No newline at end of file diff --git a/client/imports/app/quizMaster/manage/barchart/barchart.component.ts b/client/imports/app/quizMaster/manage/barchart/barchart.component.ts new file mode 100644 index 0000000..6b11934 --- /dev/null +++ b/client/imports/app/quizMaster/manage/barchart/barchart.component.ts @@ -0,0 +1,134 @@ +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { ChartsModule } from 'ng2-charts'; + +@Component({ + selector: 'bar-chart', + template: ` +
+
+ +
+
` +}) +export class BarChartComponent implements OnChanges{ + @Input() rightAnswer:number; + @Input() answer1:string; + @Input() answer2:string; + @Input() answer3:string; + @Input() answer4:string; + @Input() votes1:number; + @Input() votes2:number; + @Input() votes3:number; + @Input() votes4:number; + + public barChartOptions:any = { + scaleShowVerticalLines: false, + responsive: true, + scales: { + yAxes: [{ + ticks: { + beginAtZero:true, + userCallback: function(label, index, labels) { + // when the floored value is the same as the value we have a whole number + if (Math.floor(label) === label) { + return label; + } + + }, + } + }] + } + }; + private greenColor:any = + { + backgroundColor: "rgba(0,192,0,0.9)", + borderColor: "rgba(0,192,0,1)", + pointBackgroundColor: 'rgba(0,192,0,1)', + pointBorderColor: '#fff', + pointHoverBackgroundColor: '#fff', + pointHoverBorderColor: 'rgba(0,192,0,0.8)' + }; + private redColor:any = + { + backgroundColor: "rgba(192,0,0,0.9)", + borderColor: "rgba(192,0,0,1)", + pointBackgroundColor: 'rgba(192,0,0,1)', + pointBorderColor: '#fff', + pointHoverBackgroundColor: '#fff', + pointHoverBorderColor: 'rgba(192,0,0,0.8)' + }; + public barChartColors:Array = [ + this.greenColor, this.redColor + ]; + public barChartLabels:string[] = ['answers']; + public barChartType:string = 'bar'; + public barChartLegend:boolean = true; + + public barChartData:any[] = [ + {data: [this.votes1], label: this.answer1}, + {data: [this.votes2], label: this.answer2}, + {data: [this.votes3], label: this.answer3}, + {data: [this.votes4], label: this.answer4} + ]; + + ngOnChanges(changes: SimpleChanges) { + for (let propName in changes) { + let chng = changes[propName]; + console.log(propName + ': ' + chng.currentValue); + switch (propName) + { + case 'rightAnswer' : + switch(chng.currentValue) + { + case 1 : + this.barChartColors = [this.greenColor, this.redColor, this.redColor, this.redColor]; + break; + case 2 : + this.barChartColors = [this.redColor, this.greenColor, this.redColor, this.redColor]; + break; + case 3 : + this.barChartColors = [this.redColor, this.redColor, this.greenColor, this.redColor]; + break; + case 4 : + this.barChartColors = [this.redColor, this.redColor, this.redColor, this.greenColor]; + break; + default: + this.barChartColors = [this.redColor, this.redColor, this.redColor, this.redColor]; + } + break; + case 'answer1' : + this.barChartData[0].label = chng.currentValue; + break; + case 'answer2' : + this.barChartData[1].label = chng.currentValue; + break; + case 'answer3' : + this.barChartData[2].label = chng.currentValue; + break; + case 'answer4' : + this.barChartData[3].label = chng.currentValue; + break; + case 'votes1' : + this.barChartData[0].data[0] = chng.currentValue; + break; + case 'votes2' : + this.barChartData[1].data[0] = chng.currentValue; + break; + case 'votes3' : + this.barChartData[2].data[0] = chng.currentValue; + break; + case 'votes4' : + this.barChartData[3].data[0] = chng.currentValue; + break; + default : ; + } + } + } +} + diff --git a/client/imports/app/quizMaster/manage/leaderboard/leaderboard.component.ts b/client/imports/app/quizMaster/manage/leaderboard/leaderboard.component.ts new file mode 100644 index 0000000..9ad6808 --- /dev/null +++ b/client/imports/app/quizMaster/manage/leaderboard/leaderboard.component.ts @@ -0,0 +1,22 @@ +import {Component, Input, OnInit, OnChanges, SimpleChanges} from "@angular/core"; +import {Player} from "../../../../../../both/models/player.model"; + +@Component({ + selector: 'leaderboard', + template: ` +
    +
  • {{player.name}} - {{player.score}}
  • +
` +}) +export class LeaderboardComponent implements OnInit, OnChanges { + @Input() players: Player[]; + sortedPlayers: Player[]; + + ngOnInit() { + this.sortedPlayers = this.players.sort((a, b) => a.score - b.score); + } + + ngOnChanges(changes: SimpleChanges) { + this.sortedPlayers = changes['players'].currentValue.sort((a, b) => b.score - a.score); + } +} \ No newline at end of file diff --git a/client/imports/app/quizMaster/manage/manage.component.html b/client/imports/app/quizMaster/manage/manage.component.html new file mode 100644 index 0000000..b9f0b71 --- /dev/null +++ b/client/imports/app/quizMaster/manage/manage.component.html @@ -0,0 +1,35 @@ +
+

{{currentTimer}}

+
{{question}}
+
+
+ + +
+
+ + +
+
+ +
+
+
+

{{question}}

+ + +
+
+

Leaderboard

+ +
+
\ No newline at end of file diff --git a/client/imports/app/quizMaster/manage/manage.component.scss b/client/imports/app/quizMaster/manage/manage.component.scss new file mode 100644 index 0000000..1077e25 --- /dev/null +++ b/client/imports/app/quizMaster/manage/manage.component.scss @@ -0,0 +1,44 @@ +.quiz { + width:90%; + left: 50%; + top: 50%; + margin:auto; + + max-width:100%; + max-height:100%; + overflow:auto; +} +.question { + border-style: solid; + padding: 30px; +} +.row { + display: flex; +} +.answer { + flex: 1; + width: auto; + margin: 10px; + padding: 20px; +} +.results { + width:90%; + left: 50%; + top: 50%; + margin:auto; + + max-width:100%; + max-height:100%; + overflow:auto; + display: flex; +} +.stats { + width: auto; + height: 600px; + flex: 1; +} +.leaderboard { + margin: 20pt 0 20pt 20pt; + width: auto; + flex: 1; +} \ No newline at end of file diff --git a/client/imports/app/quizMaster/manage/manage.component.ts b/client/imports/app/quizMaster/manage/manage.component.ts new file mode 100644 index 0000000..e591aa8 --- /dev/null +++ b/client/imports/app/quizMaster/manage/manage.component.ts @@ -0,0 +1,245 @@ +import { Component, OnInit } from '@angular/core'; + +import template from './manage.component.html'; +import style from './manage.component.scss'; +import { BarChartComponent } from './barchart/barchart.component'; +import {ActivatedRoute} from "@angular/router"; +import {Subscription} from 'rxjs/Subscription'; +import {Game} from "../../../../../both/models/game.model"; +import {MeteorObservable} from "meteor-rxjs"; +import {Question} from "../../../../../both/models/question.model"; +import {Quiz} from "../../../../../both/models/quiz.model"; +import {GameCollection} from "../../../../../both/collections/game.collection"; +import {Observable} from "rxjs"; +import {GameResultCollection} from "../../../../../both/collections/gameResult.collection"; +import {GameResult} from "../../../../../both/models/gameResult.model"; +import {GivenAnswer} from "../../../../../both/models/givenAnswers.model"; +import undefined = Match.undefined; +import {forEach} from "@angular/router/src/utils/collection"; +import {PlayerCollection} from "../../../../../both/collections/player.collection"; +import {Player} from "../../../../../both/models/player.model"; + +@Component({ + template, + styles: [style] +}) +export class ManageComponent implements OnInit { + + private routeSubscription : Subscription; + private currentQuestionSubscription : Subscription; + private answersFromCompetitorSubscription : Subscription; + private playerSubscription : Subscription; + + private timerSubscription : Subscription; + + private game : Game; + private currentQuestion : number; + private quiz : Quiz; + private results : GameResult; + private timer : number; + private players : Player[]; + + showResult : boolean = false; + + givenAnswers : number; + rightAnswer : number; + answersTotal : number; + answerResults1 : number; + answerResults2 : number; + answerResults3 : number; + answerResults4 : number; + + currentTimer : number; + + //Question properties for View + question : string; + answer1 : string; + answer2 : string; + answer3 : string; + answer4 : string; + + constructor(private activatedRoute: ActivatedRoute) { } + + ngOnInit() { + this.routeSubscription = this.activatedRoute.params.subscribe( + (param : any) => { + let gameNumber : string; + gameNumber = param['gameNumber']; + this.timer = param['timer']; + this.getGameFromServer(gameNumber); + }); + + this.currentQuestion = 0; + //Funktioniert: + // let timer = Observable.timer(5000, 1000); + // timer.subscribe(t=>{ + // this.nextQuestion(); + // }); + } + + ngOnDestroy() { + this.routeSubscription.unsubscribe(); + this.currentQuestionSubscription.unsubscribe(); + this.answersFromCompetitorSubscription .unsubscribe(); + this.playerSubscription .unsubscribe(); + } + + private getGameFromServer(gameNumber : string) : void{ + MeteorObservable.call('fetchGameByNumber', gameNumber).subscribe((game : Game) => { + this.game = game; + this.timer = game.timer; + //next async requests: + this.getQuestionsFromGame(game.quizId); + this.subscribeCurrentQuestion(game._id); + this.subscribeAnswersFromCompetitor(game.gameResultId); + this.updatePlayer(); + }, (error) => { + alert(`Error: ${error}`); + throw new Error(error); + }); + } + + private getQuestionsFromGame(quizId: string) : void { + MeteorObservable.call('fetchQuizById', quizId).subscribe((quiz : Quiz) => { + this.quiz = quiz; + this.answersTotal = quiz.questions.length; + + //Call first question in quiz + this.nextQuestion(); + }, (error) => { + alert(`Error: ${error}`); + throw new Error(error); + }); + } + + private subscribeCurrentQuestion(gameId : string) { + // https://github.com/Urigo/meteor-rxjs + this.currentQuestionSubscription = GameCollection.find(gameId) + .map(games => games[0]) // game => games[0] picks first game found by _id, should only find one game + .subscribe(game => this.updateGame(game)); + } + + private subscribeAnswersFromCompetitor(gameResultId: string) { + this.answersFromCompetitorSubscription = GameResultCollection.find(gameResultId) + .map(gameResult => gameResult[0]) + .subscribe(gameResult => this.answerFromCompetitor(gameResult)); + } + + nextQuestion() : void { + this.currentQuestion++; + if(this.quiz.questions.length >= this.currentQuestion) { + + MeteorObservable.call('changeCurrentQuestion', + this.game._id, + this.quiz.questions[this.currentQuestion - 1]).subscribe(); + + this.initializeResultProperties(); + this.setTimer(); + this.showResults(false); + } + } + + private updateGame(newGame : Game) { + this.game = newGame; + if(newGame.currentQuestion != undefined && newGame.currentQuestion != null) { + this.question = newGame.currentQuestion.question; + this.answer1 = newGame.currentQuestion.answers[0].answer; + this.answer2 = newGame.currentQuestion.answers[1].answer; + this.answer3 = newGame.currentQuestion.answers[2].answer; + this.answer4 = newGame.currentQuestion.answers[3].answer; + } + } + + private initializeResultProperties() : void { + this.answerResults1 = 0; + this.answerResults2 = 0; + this.answerResults3 = 0; + this.answerResults4 = 0; + + this.givenAnswers = 0; + } + + private setTimer() : void { + this.currentTimer = this.timer; + + let timerObservable = Observable.timer(1000, 1000) + .timeInterval() + .pluck('interval') + .take(this.timer); + + this.timerSubscription = timerObservable.subscribe(()=>{ + this.decreaseTimer(); + }); + } + + private decreaseTimer() : void { + this.currentTimer--; + if(this.currentTimer <= 0 && !this.showResult) { + this.showResults(true); + } + } + + showResults(show : boolean) : void { + if(this.game != undefined && show != undefined){ + MeteorObservable.call('toggleResults', this.game._id, show).subscribe(); + this.showResult = show; + if (show) { + this.calculateResults(); + this.updatePlayer(); + } + } + } + + private updatePlayer() { + MeteorObservable.call('fetchPlayerByGameId', this.game._id).subscribe( + (p: Player[]) => this.players = p, + (error) => console.log(error) + ); + } + + private answerFromCompetitor(gameResult: GameResult) { + this.givenAnswers++; + this.results = gameResult; + + if(this.givenAnswers >= this.game.players.length) { + this.timerSubscription.unsubscribe(); + this.showResults(true); + + } + } + + private calculateResults() { + let givenAnswers : GivenAnswer[] = this.results.givenAnswers[this.currentQuestion - 1]; + for(let i = 0; i < 4; i++) { + if(this.quiz.questions[this.currentQuestion - 1].answers[i].right) { + this.rightAnswer = i + 1; + break; + } + } + + if(givenAnswers != undefined) { + for(let givenAnswer of givenAnswers) { + if(givenAnswer.givenAnswer == this.rightAnswer) { + let scoreIncrease : number; + let scoreDecrease : number; + + scoreIncrease = this.timer * 1000; + scoreDecrease = givenAnswer.timeAnswered - this.game.questionStarted; + scoreIncrease = scoreIncrease - scoreDecrease; + MeteorObservable.call("updateScore", givenAnswer.playerId, scoreIncrease).subscribe(); + } + + if(givenAnswer.givenAnswer == 1) { + this.answerResults1++; + } else if(givenAnswer.givenAnswer == 2) { + this.answerResults2++; + } else if(givenAnswer.givenAnswer == 3) { + this.answerResults3++; + }else if(givenAnswer.givenAnswer == 4) { + this.answerResults4++; + } + } + } + + } +} \ No newline at end of file diff --git a/client/imports/app/quizMaster/quizMaster.module.ts b/client/imports/app/quizMaster/quizMaster.module.ts new file mode 100644 index 0000000..7ec4220 --- /dev/null +++ b/client/imports/app/quizMaster/quizMaster.module.ts @@ -0,0 +1,41 @@ +import { NgModule } from '@angular/core'; +import { RouterModule} from '@angular/router'; + +import { CreateComponent } from './create/create.component'; +import { ManageComponent } from './manage/manage.component'; +import { StartComponent } from './start/start.component'; +import { ListComponent } from './list/list.component'; +import { BarChartComponent } from './manage/barchart/barchart.component'; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { MaterialModule } from "@angular/material"; +import {BrowserModule} from "@angular/platform-browser"; +import { ChartsModule } from 'ng2-charts'; +import {LeaderboardComponent} from "./manage/leaderboard/leaderboard.component"; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: 'master/create', component: CreateComponent }, + { path: 'master/manage/:gameNumber', component: ManageComponent }, + { path: 'master/manage/:gameNumber', component: ManageComponent }, + { path: 'master/start/:quizId', component: StartComponent }, + { path: 'master/list', component: ListComponent} + ]), + FormsModule, + ReactiveFormsModule, + MaterialModule.forRoot(), + BrowserModule, + ChartsModule + ], + exports: [], + declarations: [ + CreateComponent, + ManageComponent, + StartComponent, + ListComponent, + BarChartComponent, + LeaderboardComponent + ], + providers: [], +}) +export class QuizMasterModule { } diff --git a/client/imports/app/quizMaster/start/start.component.html b/client/imports/app/quizMaster/start/start.component.html new file mode 100644 index 0000000..b856164 --- /dev/null +++ b/client/imports/app/quizMaster/start/start.component.html @@ -0,0 +1,17 @@ +
+
+

{{quizName}}

+

This Quizza has {{questions}} question(s).

+

The game ID for this Quizza is {{gameNumber}}

+ + Start Quizza +
+
+

Players:

+
+
{{player}}
+
+
+
\ No newline at end of file diff --git a/client/imports/app/quizMaster/start/start.component.scss b/client/imports/app/quizMaster/start/start.component.scss new file mode 100644 index 0000000..154c544 --- /dev/null +++ b/client/imports/app/quizMaster/start/start.component.scss @@ -0,0 +1,31 @@ +.startPage { + width:70%; + left: 50%; + top: 50%; + margin:auto; + + max-width:100%; + max-height:100%; + overflow:auto; + display: flex; +} + +.gameInfo { + width: auto; + flex: 1; + display: inline-block; + margin: 10px; +} + +.playerList { + width: auto; + flex: 1; + display: inline-block; + margin: 10px; +} + +.player { + margin: 5px; + padding: 5px; + display: inline; +} \ No newline at end of file diff --git a/client/imports/app/quizMaster/start/start.component.ts b/client/imports/app/quizMaster/start/start.component.ts new file mode 100644 index 0000000..85d5c09 --- /dev/null +++ b/client/imports/app/quizMaster/start/start.component.ts @@ -0,0 +1,118 @@ +import {Component, OnInit, OnDestroy} from '@angular/core'; + +import template from './start.component.html'; +import style from './start.component.scss'; +import {ActivatedRoute, Router} from "@angular/router"; +import { Subscription } from 'rxjs/Subscription'; +import {MeteorObservable} from "meteor-rxjs"; +import {Quiz} from "../../../../../both/models/quiz.model"; +import {Game} from "../../../../../both/models/game.model"; +import {Player} from "../../../../../both/models/player.model"; +import {GameCollection} from "../../../../../both/collections/game.collection"; +import {GameResult} from "../../../../../both/models/gameResult.model"; +import {ok} from "assert"; + +@Component({ + template, + styles: [style] +}) +export class StartComponent implements OnInit, OnDestroy { + + private subscription: Subscription; + private gameSubscription: Subscription; + + quizId: string; + quizName: string; + questions: number; + players: string[]; + gameNumber: string; + gameId : string; + timer : number; + + + constructor(private activatedRoute: ActivatedRoute, private router : Router) { } + + ngOnInit() { + this.timer = 20; + + // subscribe to router event + this.subscription = this.activatedRoute.params.subscribe( + (param: any) => { + this.quizId = param['quizId']; + this.getQuizDetails(this.quizId); + this.initGame(); + }); + } + + ngOnDestroy() { + // prevent memory leak by unsubscribing + this.subscription.unsubscribe(); + this.gameSubscription.unsubscribe(); + } + + initGame() { + //generate gameNumber + this.generateGameNumber(this.quizId); + this.players = ["no Players"]; + } + + getQuizDetails(quizId: string) { + MeteorObservable.call('fetchQuizById', quizId).subscribe((quiz : Quiz) => { + this.quizName = quiz.name; + this.questions = quiz.questions.length; + }, (error) => { + alert(`Error: ${error}`); + }); + } + + startQuiz() { + MeteorObservable.call("startGame", this.gameId, this.timer).subscribe((success : boolean) => { + if(success) { + this.router.navigateByUrl('master/manage/' + this.gameNumber); + } + }); + + + } + + private generateGameNumber(quizId: string) { + MeteorObservable.call("addGameResult", quizId).subscribe((gameResult : GameResult) => { + this.createGame(quizId, gameResult._id) + }, (error) => { + alert(error); + }); + + } + + private createGame(quizId : string, gameResultId : string) { + MeteorObservable.call('addGame', quizId, gameResultId).subscribe((game : Game) => { + this.gameNumber = game.gameNumber; + this.gameId = game._id; + this.subscribeGame(game._id); + }, (error) =>{ + alert(error); + }); + } + + private subscribeGame(gameId: string) { + // https://github.com/Urigo/meteor-rxjs + this.gameSubscription = GameCollection.find({_id: gameId}) + .map(games => games[0]) // game => games[0] picks first game found by _id, should only find one game + .subscribe(game => this.fetchPlayersFromGame(game)); + } + + private fetchPlayersFromGame(game : Game) { + let players :Player[] = game.players; + if ( players != null && players.length > 0) { + this.parsePlayerArray(players); + } + } + + private parsePlayerArray(players: Player[]) { + let tmpArray: string[] = []; + for (let player of players) { + tmpArray.push(player.name); + } + this.players = tmpArray; + } +} \ No newline at end of file diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..9cc06b1 --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + + + + + + +

Quizza is loading...

+ +
+ + diff --git a/client/lib/init.test.ts b/client/lib/init.test.ts new file mode 100644 index 0000000..c593bca --- /dev/null +++ b/client/lib/init.test.ts @@ -0,0 +1,29 @@ +// angular2-meteor polyfills +import "angular2-meteor-polyfills"; +import "zone.js/dist/async-test"; +import "zone.js/dist/fake-async-test"; +import "zone.js/dist/sync-test"; +import "zone.js/dist/proxy"; + +// angular2-meteor polyfills required for testing +import "angular2-meteor-tests-polyfills"; + +// Angular 2 tests imports +import { TestBed, getTestBed } from "@angular/core/testing"; +import { platformBrowserDynamicTesting, BrowserDynamicTestingModule } from "@angular/platform-browser-dynamic/testing"; + +// Init the test framework +TestBed.initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +); + +declare var Mocha: {Hook: any}, mocha: { suite: { _beforeEach: any, ctx: any }}; + +const hook = new Mocha.Hook("Modified Angular beforeEach Hook", () => { + getTestBed().resetTestingModule(); +}); + +hook.ctx = mocha.suite.ctx; +hook.parent = mocha.suite; +mocha.suite._beforeEach = [hook]; diff --git a/client/main.ts b/client/main.ts new file mode 100644 index 0000000..11eef4f --- /dev/null +++ b/client/main.ts @@ -0,0 +1,12 @@ +import "angular2-meteor-polyfills"; + +import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; +import { enableProdMode } from "@angular/core"; +import { Meteor } from "meteor/meteor"; +import { AppModule } from "./imports/app"; + +enableProdMode(); + +Meteor.startup(() => { + platformBrowserDynamic().bootstrapModule(AppModule); +}); diff --git a/client/styles/main.scss b/client/styles/main.scss new file mode 100644 index 0000000..c82c7e6 --- /dev/null +++ b/client/styles/main.scss @@ -0,0 +1,23 @@ +@import "{}/node_modules/@angular/material/core/theming/all-theme"; +@include md-core(); + +$app-primary: md-palette($md-light-blue, 500, 100, 700); +$app-accent: md-palette($md-pink, A200, A100, A400); +$app-warn: md-palette($md-red); +$app-theme: md-light-theme($app-primary, $app-accent, $app-warn); +@include angular-material-theme($app-theme); + +.fill-remaining-space { + flex: 1 1 auto; +} + +body { + background-color: #f8f8f8; + font-family: 'Muli', sans-serif; + padding: 0; + margin: 0; +} + +.full-width { + width: 100%; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6664a41 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "angular2-meteor-base", + "private": true, + "scripts": { + "start": "meteor run", + "start:prod": "meteor run --production", + "build": "meteor build ./build/", + "clear": "meteor reset", + "meteor:update": "meteor update --all-packages", + "test": "meteor test --driver-package practicalmeteor:mocha", + "test:ci": "meteor test --once --driver-package dispatch:mocha-phantomjs" + }, + "devDependencies": { + "@types/chai": "^3.4.33", + "@types/mocha": "^2.2.32", + "chai": "3.5.0", + "chai-spies": "0.7.1" + }, + "dependencies": { + "@angular/common": "2.3.0", + "@angular/compiler": "2.3.0", + "@angular/core": "2.3.0", + "@angular/forms": "2.3.0", + "@angular/http": "2.3.0", + "@angular/material": "^2.0.0-alpha.9-3", + "@angular/platform-browser": "2.3.0", + "@angular/platform-browser-dynamic": "2.3.0", + "@angular/router": "3.3.0", + "angular2-meteor": "0.7.1", + "angular2-meteor-polyfills": "0.1.1", + "angular2-meteor-tests-polyfills": "0.0.2", + "babel-runtime": "^6.20.0", + "chart.js": "^2.4.0", + "meteor-node-stubs": "0.2.3", + "meteor-rxjs": "0.4.5", + "meteor-typings": "1.3.1", + "ng2-charts": "^1.5.0", + "reflect-metadata": "0.1.8", + "rxjs": "5.0.0-rc.4", + "zone.js": "0.7.2" + } +} diff --git a/public/quizza.png b/public/quizza.png new file mode 100644 index 0000000..ca13b64 Binary files /dev/null and b/public/quizza.png differ diff --git a/server/imports/server-main/main.test.ts b/server/imports/server-main/main.test.ts new file mode 100644 index 0000000..bb424fa --- /dev/null +++ b/server/imports/server-main/main.test.ts @@ -0,0 +1,31 @@ +// chai uses as asset library +import * as chai from "chai"; +import * as spies from "chai-spies"; +import StubCollections from "meteor/hwillson:stub-collections"; + +import { Main } from "./main"; + +chai.use(spies); + +describe("Server Main", () => { + let mainInstance: Main; + + beforeEach(() => { + + // Create instance of main class + mainInstance = new Main(); + }); + + afterEach(() => { + // Restore database + StubCollections.restore(); + }); + + it("Should call initFakeData on startup", () => { + mainInstance.initFakeData = chai.spy(); + mainInstance.start(); + + chai.expect(mainInstance.initFakeData).to.have.been.called(); + }); + +}); diff --git a/server/imports/server-main/main.ts b/server/imports/server-main/main.ts new file mode 100644 index 0000000..f36b8be --- /dev/null +++ b/server/imports/server-main/main.ts @@ -0,0 +1,5 @@ + +export class Main { + start(): void { + } +} diff --git a/server/main.ts b/server/main.ts new file mode 100644 index 0000000..6e8b1af --- /dev/null +++ b/server/main.ts @@ -0,0 +1,5 @@ +import { Main } from "./imports/server-main/main"; +import './methods/quiz.methods'; + +const mainInstance = new Main(); +mainInstance.start(); diff --git a/server/methods/game.methods.ts b/server/methods/game.methods.ts new file mode 100644 index 0000000..2d07793 --- /dev/null +++ b/server/methods/game.methods.ts @@ -0,0 +1,80 @@ +import {Meteor} from 'meteor/meteor'; +import {GameCollection} from "../../both/collections/game.collection"; +import {Game} from "../../both/models/game.model"; +import {Player} from "../../both/models/player.model"; +import {Question} from "../../both/models/question.model"; +import {GivenAnswer} from "../../both/models/givenAnswers.model"; +import undefined = Match.undefined; + + +Meteor.methods({ + addGame: function(quizId: string, gameResultId : string) { + let game = new Game; + game.quizId = quizId; + game.gameResultId = gameResultId; + game.currentIndex = 0; + game.gameNumber = String(genGameNumber()); + game.running = false; + game.players = []; + game.showResult = false; + game.timer = 20; + game.questionStarted = 0; + + let id : string; + id = GameCollection.collection.insert(game); + return GameCollection.findOne(id); + }, + + fetchGameByNumber: function(gameNumber: string) { + //search for games by gameNumber + return GameCollection.findOne({gameNumber: gameNumber}); + }, + fetchNotRunningGameByNumber : function(gameNumber : string) { + return GameCollection.findOne({gameNumber : gameNumber, running : false}); + }, + joinGame: function(gameId:string, player: Player) { + let game = GameCollection.findOne({_id: gameId, running: false}); + if (game == undefined) { + return false; + } + let players = game.players; + players.push(player); + GameCollection.update({_id: gameId}, {$set: {players: players}}); + return true; + }, + fetchGameById: function(gameId : string) : Game { + return GameCollection.findOne({_id : gameId,}); + }, + changeCurrentQuestion: function(gameId:string, question : Question) { + let game = GameCollection.findOne(gameId); + let questionStarted = Date.now(); + + GameCollection.update(gameId, {$set: { + currentQuestion : question, + currentIndex : ++game.currentIndex, + questionStarted : questionStarted + }}); + }, + toggleResults: function(gameId:string, showResults : boolean) { + GameCollection.update(gameId, {$set: { + showResult : showResults + }}); + }, + startGame: function (gameId: string, timer : number) : boolean{ + let game = GameCollection.findOne({_id : gameId, running : false}); + + if (game == undefined) { + return false; + } + GameCollection.update(game._id, {$set: { + running : true, + timer : timer}}); + return true; + } +}); + +function genGameNumber() { + let min = 100000; + let max = 999999; + return Math.floor(Math.random()* (max-min+1)+min); +} diff --git a/server/methods/gameResult.methods.ts b/server/methods/gameResult.methods.ts new file mode 100644 index 0000000..51d9a54 --- /dev/null +++ b/server/methods/gameResult.methods.ts @@ -0,0 +1,53 @@ +import {Meteor} from 'meteor/meteor'; +import {GameCollection} from "../../both/collections/game.collection"; +import {GivenAnswer} from "../../both/models/givenAnswers.model"; +import {GameResultCollection} from "../../both/collections/gameResult.collection"; +import {GameResult} from "../../both/models/gameResult.model"; +import {Quiz} from "../../both/models/quiz.model"; +import {QuizCollection} from "../../both/collections/quiz.collection"; +import {forEach} from "@angular/router/src/utils/collection"; + + +Meteor.methods({ + addGameResult : function(quizId : string) : GameResult { + let gameResult = new GameResult(); + let quiz : Quiz; + + quiz = QuizCollection.findOne(quizId); + + gameResult.quizId = quizId; + + for(let i = 0; i < quiz.questions.length; i++) { + gameResult.givenAnswers[i] = []; + } + + let id : string; + id = GameResultCollection.collection.insert(gameResult); + + return GameResultCollection.findOne(id); + }, + answerFromPlayer : function( + gameId : string, + playerId : string, + answerNo : number) : boolean { + + let game = GameCollection.findOne(gameId); + let gameResult = GameResultCollection.findOne(game.gameResultId); + + if(gameResult.givenAnswers[game.currentIndex - 1].filter(a => a.playerId == playerId).length == 0) { + let answer = new GivenAnswer(); + + answer.playerId = playerId; + answer.givenAnswer = answerNo; + answer.timeAnswered = Date.now(); + + gameResult.givenAnswers[game.currentIndex - 1].push(answer); + + GameResultCollection.update(gameResult._id, {$set : {givenAnswers : gameResult.givenAnswers}}); + + return true; + } + + return false; + } +}); \ No newline at end of file diff --git a/server/methods/player.methods.ts b/server/methods/player.methods.ts new file mode 100644 index 0000000..fe833be --- /dev/null +++ b/server/methods/player.methods.ts @@ -0,0 +1,42 @@ +import {Meteor} from 'meteor/meteor'; +import {GameCollection} from "../../both/collections/game.collection"; +import {Player} from "../../both/models/player.model"; +import {PlayerCollection} from "../../both/collections/player.collection"; +import undefined = Match.undefined; + + +Meteor.methods({ + addPlayer: function(gameId: string, name: string) { + + let player = new Player(); + player.gameId = gameId; + player.name = name; + player.playing = true; + player.score = 0; + + let id : string; + id = PlayerCollection.collection.insert(player); + return PlayerCollection.findOne(id); + }, + + fetchPlayerById: function(playerId: string) { + //search for playing players by playerNumber + return PlayerCollection.findOne({_id: playerId, playing: true}); + }, + + fetchPlayerByGameId: function(gameId: string) { + let res = PlayerCollection.collection.find({gameId : gameId}).fetch(); + console.log(gameId); + console.log(res); + return res; + }, + + updateScore: function(playerId: string, addToScore : number) { + let player = PlayerCollection.findOne(playerId); + if (player == undefined) { + return; + } + PlayerCollection.update(playerId, {$set: {score: player.score + addToScore}}); + } +}); + diff --git a/server/methods/quiz.methods.ts b/server/methods/quiz.methods.ts new file mode 100644 index 0000000..dcaece6 --- /dev/null +++ b/server/methods/quiz.methods.ts @@ -0,0 +1,20 @@ +import {Meteor} from 'meteor/meteor'; +import {Quiz} from "../../both/models/quiz.model"; +import {QuizCollection} from "../../both/collections/quiz.collection"; +import {Game} from "../../both/models/game.model"; +import {GivenAnswer} from "../../both/models/givenAnswers.model"; +import undefined = Match.undefined; + + +Meteor.methods({ + saveQuiz: function(quizModel: Quiz) { + let id : string; + id = QuizCollection.collection.insert(quizModel); + + return QuizCollection.findOne(id); + }, + + fetchQuizById: function(quizId: string) { + return QuizCollection.findOne(quizId); + } +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..59f7332 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "es6", + "dom" + ], + "module": "commonjs", + "moduleResolution": "node", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "sourceMap": true + }, + "exclude": [ + "node_modules" + ], + "files": [ + "typings.d.ts" + ], + "compileOnSave": false, + "angularCompilerOptions": { + "genDir": "aot", + "skipMetadataEmit": true + } +} diff --git a/typings.d.ts b/typings.d.ts new file mode 100644 index 0000000..67d531b --- /dev/null +++ b/typings.d.ts @@ -0,0 +1,29 @@ +/// +/// +/// + +declare module "*.html" { + const template: string; + export default template; +} + +declare module "*.scss" { + const style: string; + export default style; +} + +declare module "*.less" { + const style: string; + export default style; +} + +declare module "*.css" { + const style: string; + export default style; +} + +declare module "*.sass" { + const style: string; + export default style; +} +