├── .babelrc ├── .github └── winsome.png ├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── src ├── App.vue ├── assets │ └── winsome-trivia.png ├── components │ ├── About.vue │ ├── GameBoard.vue │ ├── GameOverModal.vue │ ├── Instructions.vue │ ├── Intro.vue │ ├── Loader.vue │ ├── Questions.vue │ ├── Score.vue │ ├── ScoreDots.vue │ ├── StarPower.vue │ └── Starter.vue ├── htmlEntityMixin.js ├── main.js ├── main.scss └── store │ └── store.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["latest", { 4 | "es2015": { "modules": false } 5 | }] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.github/winsome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splode/open-trivia-app/11220dc7c5847bd1a1bd807bb045a30f17a032a1/.github/winsome.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log 5 | yarn-error.log 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Christopher Murphy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Winsome Trivia 2 | 3 | A single or multiplayer (local) trivia game made with featuring over 2,000 unique questions and built with Vue.js. 4 | 5 |
6 | Winsome Trivia screenshot 7 |
8 | 9 | ## About 10 | A trivia web app built with Vue.js and powered by the [Open Trivia Database](https://opentdb.com/). 11 | 12 | State is managed by [Vuex](https://github.com/vuejs/vuex). Database requests are handled by [vue-resource](https://github.com/pagekit/vue-resource). 13 | 14 | ## License 15 | MIT 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | Winsome Trivia 24 | 25 | 26 | 27 |
28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "open-trivia-app", 3 | "description": "A single or multiplayer (local) trivia game made with Vue.js .", 4 | "version": "1.0.0", 5 | "author": "Christopher Murphy (christopherianmurphy@gmail.com)", 6 | "license": "MIT", 7 | "private": true, 8 | "scripts": { 9 | "dev": "cross-env NODE_ENV=development webpack-dev-server --open --hot", 10 | "build": "cross-env NODE_ENV=production webpack --progress --hide-modules" 11 | }, 12 | "dependencies": { 13 | "vue": "^2.2.1", 14 | "vue-resource": "^1.2.1", 15 | "vuex": "^2.2.1" 16 | }, 17 | "devDependencies": { 18 | "babel-core": "^6.0.0", 19 | "babel-loader": "^6.0.0", 20 | "babel-preset-latest": "^6.0.0", 21 | "cross-env": "^3.0.0", 22 | "css-loader": "^0.25.0", 23 | "file-loader": "^0.9.0", 24 | "node-sass": "^4.5.0", 25 | "sass-loader": "^5.0.1", 26 | "vue-loader": "^11.1.4", 27 | "vue-template-compiler": "^2.2.1", 28 | "webpack": "^2.2.0", 29 | "webpack-dev-server": "^2.2.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 91 | 92 | 199 | -------------------------------------------------------------------------------- /src/assets/winsome-trivia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splode/open-trivia-app/11220dc7c5847bd1a1bd807bb045a30f17a032a1/src/assets/winsome-trivia.png -------------------------------------------------------------------------------- /src/components/About.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 17 | 18 | 41 | -------------------------------------------------------------------------------- /src/components/GameBoard.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 34 | 35 | 65 | -------------------------------------------------------------------------------- /src/components/GameOverModal.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 71 | 72 | 153 | -------------------------------------------------------------------------------- /src/components/Instructions.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | 13 | 34 | -------------------------------------------------------------------------------- /src/components/Intro.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/Loader.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 77 | 78 | 152 | -------------------------------------------------------------------------------- /src/components/Questions.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 97 | 98 | 133 | -------------------------------------------------------------------------------- /src/components/Score.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 31 | 32 | 56 | -------------------------------------------------------------------------------- /src/components/ScoreDots.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 54 | 55 | 90 | -------------------------------------------------------------------------------- /src/components/StarPower.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 24 | 25 | 92 | -------------------------------------------------------------------------------- /src/components/Starter.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 90 | 91 | 172 | -------------------------------------------------------------------------------- /src/htmlEntityMixin.js: -------------------------------------------------------------------------------- 1 | export const htmlEntity = { 2 | methods: { 3 | decode(str) { 4 | return str.replace(/&[#039]*;/g, "'") 5 | .replace(/&[amp]*;/g, '&') 6 | .replace(/&[quot]*;/g, '"') 7 | .replace(/&[rsquo]*;/g, '’') 8 | .replace(/&[lsquo]*;/g, '‘') 9 | .replace(/&[ldquo]*;/g, '“') 10 | .replace(/&[rdquo]*;/g, '”') 11 | .replace(/&[apos]*;/g, "'") 12 | .replace(/&[hellip]*;/g, "…") 13 | .replace(/&[percnt]*;/g, '%') 14 | .replace(/&[divide]*;/g, '÷') 15 | .replace(/&[div]*;/g, '÷') 16 | .replace(/&[lt]*;/g, '<') 17 | .replace(/&[gt]*;/g, '>') 18 | .replace(/&[sup2]*;/g, '²') 19 | .replace(/&[deg]*;/g, '°') 20 | // FIXME: regex matching 'eacute' 21 | // .replace(/&[aacute]*;/g, 'á') 22 | .replace(/&[eacute]*;/g, 'é') 23 | .replace(/&[iacute]*;/g, 'í') 24 | .replace(/&[ntilde]*;/g, 'ñ') 25 | .replace(/&[oacute]*;/g, 'ó') 26 | .replace(/&[uacute]*;/g, 'ú') 27 | .replace(/&[auml]*;/g, 'ä') 28 | .replace(/&[euml]*;/g, 'ë') 29 | .replace(/&[iuml]*;/g, 'ï') 30 | .replace(/&[ouml]*;/g, 'ö') 31 | .replace(/&[uuml]*;/g, 'ü') 32 | .replace(/&[yuml]*;/g, 'ÿ') 33 | .replace(/&[uuml]*;/g, 'ü') 34 | .replace(/&[scaron]*;/g, 'š') 35 | .replace(/&[epsilon]*;/g, 'ε') 36 | .replace(/&[Phi]*;/g, 'φ') 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueResource from 'vue-resource'; 3 | import App from './App.vue' 4 | import { 5 | store 6 | } from './store/store'; 7 | 8 | Vue.use(VueResource); 9 | 10 | new Vue({ 11 | el: '#app', 12 | store, 13 | render: h => h(App) 14 | }) 15 | -------------------------------------------------------------------------------- /src/main.scss: -------------------------------------------------------------------------------- 1 | @import url( 'https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,900'); 2 | @import url( 'https://fonts.googleapis.com/css?family=Quicksand:400,700'); 3 | @import url( 'https://fonts.googleapis.com/icon?family=Material+Icons'); 4 | $color-darkest: #0277BD; 5 | $color-dark: #03A9F4; 6 | $color-med: #4FC3F7; 7 | $color-alt-dark: #5C6BC0; 8 | $color-alt-med: #7986CB; 9 | $color-white: whitesmoke; 10 | $color-green: #5CDB80; 11 | $color-red: #FF5593; 12 | $back-gradient: linear-gradient(to top, #4481eb 0%, #04befe 100%); 13 | 14 | body, 15 | ul { 16 | margin: 0; 17 | padding: 0; 18 | } 19 | 20 | a, a.link-light, a.link-dark { 21 | color: inherit; 22 | position: relative; 23 | text-decoration: none; 24 | transition: all .3s ease; 25 | 26 | &::before { 27 | bottom: -2px; 28 | right: 0; 29 | content: ''; 30 | position: absolute; 31 | transform: scale(1); 32 | transition: all .3s ease; 33 | width: 100%; 34 | height: 2px; 35 | } 36 | 37 | &:hover::before { 38 | transform: scale(0); 39 | } 40 | } 41 | 42 | a.link-light { 43 | &:hover { 44 | color: $color-med; 45 | } 46 | &::before { 47 | background-color: $color-white; 48 | } 49 | } 50 | 51 | a.link-dark { 52 | &:hover { 53 | color: $color-white; 54 | } 55 | &::before { 56 | background-color: $color-med; 57 | } 58 | } 59 | 60 | button { 61 | align-self: center; 62 | background-color: $color-white; 63 | border: 2px solid $color-white; 64 | //border: 0; 65 | border-radius: 190px; 66 | color: $color-darkest; 67 | cursor: pointer; 68 | font-family: 'Quicksand', sans-serif; 69 | font-size: 1.25em; 70 | margin: .5em 0; 71 | outline: none; 72 | padding: 1em; 73 | transition: all 0.25s ease; 74 | max-width: 400px; 75 | //width: 200px; 76 | width: 100%; 77 | &:disabled, &:disabled:hover { 78 | cursor: auto; 79 | pointer-events: none; 80 | } 81 | } 82 | 83 | h2 { 84 | font-weight: normal; 85 | } 86 | 87 | ul { 88 | list-style-type: none; 89 | } 90 | 91 | @media (hover:hover) { 92 | button:hover, button:active { 93 | background-color: $color-med; 94 | border: 2px solid $color-med; 95 | color: $color-white; 96 | } 97 | } 98 | 99 | @media (max-width: 600px) { 100 | body { 101 | font-size: .85em; 102 | } 103 | } 104 | 105 | .flex-center-col { 106 | align-items: center; 107 | display: flex; 108 | flex-direction: column; 109 | justify-content: center; 110 | } 111 | 112 | .fade-enter-active, 113 | .fade-leave-active { 114 | transition: all 0.3s ease; 115 | } 116 | 117 | .fade-enter, 118 | .fade-leave-to { 119 | opacity: 0; 120 | } 121 | 122 | // Styling for score displays 123 | .score-high { 124 | color: $color-green; 125 | } 126 | 127 | .score-low { 128 | color: $color-red; 129 | } 130 | 131 | @keyframes fade-in { 132 | from { 133 | opacity: 0; 134 | } 135 | to { 136 | opacity: 1; 137 | } 138 | } 139 | 140 | @keyframes open { 141 | from { 142 | max-height: 0; 143 | opacity: 0; 144 | margin-bottom: 0; 145 | } 146 | to { 147 | max-height: 1000px; 148 | opacity: 1; 149 | margin-bottom: 2em; 150 | } 151 | } 152 | 153 | @keyframes close { 154 | from { 155 | max-height: 1000px; 156 | opacity: 1; 157 | margin-bottom: 2em; 158 | } 159 | to { 160 | max-height: 0; 161 | opacity: 0; 162 | margin-bottom: 0; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/store/store.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | 4 | Vue.use(Vuex); 5 | 6 | export const store = new Vuex.Store({ 7 | state: { 8 | aboutShow: false, // Show or hide About menu 9 | categories: [], // trivia categories 10 | currentCategory: { // chosen category 11 | name: 'Random', 12 | id: 9, 13 | }, 14 | currentView: 'app-intro', 15 | questions: [], // current list of questions in game 16 | isGameOver: false, // game state 17 | isPaused: false, 18 | round: 0, // round counter, starts at 0, ends at 9. Linked to display of current question 19 | scores: { 20 | playerOne: { 21 | history: [], 22 | total: 0, 23 | }, 24 | playerTwo: { 25 | history: [], 26 | total: 0, 27 | } 28 | }, 29 | solo: true, // Game mode, solo or multiplayer? 30 | starPower: false, // show starPower animation 31 | }, 32 | // =============== ACTIONS =============== 33 | actions: { 34 | // Call starPower mutation and set delay toggle 35 | starPower(context) { 36 | context.commit('starPower'); 37 | let vm = context; 38 | setTimeout(() => { 39 | vm.state.starPower = false; 40 | }, 1600); 41 | }, 42 | // Called by Starter.vue. 43 | startGame(context) { 44 | context.state.currentView = 'app-loader'; 45 | // Fetch batch of questions for specific category 46 | let api; 47 | // Determine if random (default) or chosen category 48 | if (context.state.currentCategory.name === 'Random') { 49 | api = 'https://opentdb.com/api.php?amount=10'; 50 | } else { 51 | api = 'https://opentdb.com/api.php?amount=10&category=' + context.state.currentCategory.id; 52 | } 53 | Vue.http.get(api) 54 | .then(response => { 55 | return response.json(); 56 | }) 57 | .then(data => { 58 | context.commit('startGame', data.results); 59 | }); 60 | }, 61 | }, 62 | // ========== GETTERS ============ 63 | getters: { 64 | // Get solo or multiplayer 65 | mode: state => { 66 | return state.solo; 67 | }, 68 | round: state => { 69 | return state.round; 70 | }, 71 | // Determine player turn 72 | turn: state => { 73 | let check = state.round % 2; 74 | if (check === 0 || state.round === 0) { 75 | return 'playerOne'; 76 | } else { 77 | return 'playerTwo'; 78 | } 79 | }, 80 | }, 81 | // ============ MUTATIONS =============== 82 | mutations: { 83 | // Toggle display of about menu 84 | aboutToggle: state => { 85 | state.aboutShow = !state.aboutShow; 86 | }, 87 | // Set game over and show modal after 10 rounds 88 | isGameOver: state => { 89 | if (state.round === 9) { 90 | state.isGameOver = true; 91 | } 92 | }, 93 | // Next round 94 | incrementRound: state => { 95 | state.round += 1; 96 | }, 97 | // Restart game with default state 98 | newGame: state => { 99 | state.currentCategory.name = 'Random'; 100 | state.currentCategory.id = 9; 101 | state.currentView = 'app-intro'; 102 | state.solo = true; 103 | }, 104 | // Pause game state and disable answer buttons after submtting answer 105 | pauseGame: (state, payload) => { 106 | if (payload === 'pause') { 107 | state.isPaused = true; 108 | } else { 109 | state.isPaused = false; 110 | } 111 | }, 112 | // Reset common default game parameters 113 | resetGame: state => { 114 | state.isGameOver = false; 115 | state.isPaused = false; 116 | state.questions = []; 117 | state.round = 0; 118 | state.scores.playerOne.total = 0; 119 | state.scores.playerTwo.total = 0; 120 | state.scores.playerOne.history = []; 121 | state.scores.playerTwo.history = []; 122 | }, 123 | // Score after answering question 124 | score: (state, payload) => { 125 | let player = payload.mode; 126 | if (payload.true) { 127 | state.scores[player].history.push({ 128 | correct: true, 129 | incorrect: false, 130 | }); 131 | state.scores[player].total += 1; 132 | } else { 133 | state.scores[player].history.push({ 134 | correct: false, 135 | incorrect: true, 136 | }); 137 | } 138 | }, 139 | // Set game mode from Starter.vue 140 | selectMode: (state, payload) => { 141 | payload === true ? state.solo = true : state.solo = false; 142 | }, 143 | // Set current category from Starter.vue 144 | setCurrentCategory: (state, payload) => { 145 | state.currentCategory.name = payload.name; 146 | state.currentCategory.id = payload.id; 147 | }, 148 | // Call starPower 149 | starPower: (state) => { 150 | state.starPower = true; 151 | }, 152 | startGame: (state, payload) => { 153 | // Set questions to payload from http request in startGame action 154 | state.questions = payload; 155 | // Create list of incorrect choices 156 | state.questions.forEach(el => { 157 | el.choices = el.incorrect_answers.reduce((acc, item) => { 158 | acc.push({ 159 | text: item, 160 | answer: false, 161 | classes: { 162 | incorrect: false 163 | } 164 | }); 165 | return acc; 166 | }, []); 167 | // Add correct answer 168 | el.choices.push({ 169 | text: el.correct_answer, 170 | answer: true, 171 | classes: { 172 | correct: false, 173 | } 174 | }); 175 | // Shuffle choices 176 | let i = el.choices.length, temp, rand; 177 | while (0 !== i) { 178 | rand = Math.floor(Math.random() * i); 179 | i -= 1; 180 | temp = el.choices[i]; 181 | el.choices[i] = el.choices[rand]; 182 | el.choices[rand] = temp; 183 | } 184 | }); 185 | // Set view to game board 186 | state.currentView = 'app-game-board'; 187 | }, 188 | // Apply classes, which indicate correct or incorrect, to buttons after 189 | // submtting answer 190 | styleButtons: state => { 191 | state.questions[state.round].choices.forEach(el => { 192 | if (el.answer) { 193 | el.classes = { correct: true } 194 | } else { 195 | el.classes = { incorrect: true } 196 | } 197 | }); 198 | } 199 | } 200 | }); 201 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var webpack = require('webpack') 3 | 4 | module.exports = { 5 | entry: './src/main.js', 6 | output: { 7 | path: path.resolve(__dirname, './dist'), 8 | publicPath: '/dist/', 9 | filename: 'build.js' 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.vue$/, 15 | loader: 'vue-loader', 16 | options: { 17 | loaders: { 18 | // Since sass-loader (weirdly) has SCSS as its default parse mode, we map 19 | // the "scss" and "sass" values for the lang attribute to the right configs here. 20 | // other preprocessors should work out of the box, no loader config like this necessary. 21 | 'scss': 'vue-style-loader!css-loader!sass-loader', 22 | 'sass': 'vue-style-loader!css-loader!sass-loader?indentedSyntax' 23 | } 24 | // other vue-loader options go here 25 | } 26 | }, 27 | { 28 | test: /\.js$/, 29 | loader: 'babel-loader', 30 | exclude: /node_modules/ 31 | }, 32 | { 33 | test: /\.(png|jpg|gif|svg)$/, 34 | loader: 'file-loader', 35 | options: { 36 | name: '[name].[ext]?[hash]' 37 | } 38 | } 39 | ] 40 | }, 41 | resolve: { 42 | alias: { 43 | 'vue$': 'vue/dist/vue.esm.js' 44 | } 45 | }, 46 | devServer: { 47 | historyApiFallback: true, 48 | noInfo: true 49 | }, 50 | performance: { 51 | hints: false 52 | }, 53 | devtool: '#eval-source-map' 54 | } 55 | 56 | if (process.env.NODE_ENV === 'production') { 57 | module.exports.devtool = '#source-map' 58 | // http://vue-loader.vuejs.org/en/workflow/production.html 59 | module.exports.plugins = (module.exports.plugins || []).concat([ 60 | new webpack.DefinePlugin({ 61 | 'process.env': { 62 | NODE_ENV: '"production"' 63 | } 64 | }), 65 | new webpack.optimize.UglifyJsPlugin({ 66 | sourceMap: true, 67 | compress: { 68 | warnings: false 69 | } 70 | }), 71 | new webpack.LoaderOptionsPlugin({ 72 | minimize: true 73 | }) 74 | ]) 75 | } 76 | --------------------------------------------------------------------------------