Back- and frontend improvements

- added graphql backend
- added mobile support for frontend
- improved username form
- improved access to html-elements
- added toggle to bingo fields
pull/3/head
Trivernis 6 years ago
parent 5cde198890
commit 1e96cbe68e

1
.gitignore vendored

@ -1,5 +1,4 @@
package-lock package-lock
bin
.idea .idea
node_modules node_modules
scripts/* scripts/*

@ -7,6 +7,9 @@ const createError = require('http-errors'),
session = require('express-session'), session = require('express-session'),
fsx = require('fs-extra'), fsx = require('fs-extra'),
yaml = require('js-yaml'), yaml = require('js-yaml'),
graphqlHTTP = require('express-graphql'),
{ buildSchema } = require('graphql'),
{ importSchema } = require('graphql-import'),
indexRouter = require('./routes/index'), indexRouter = require('./routes/index'),
usersRouter = require('./routes/users'), usersRouter = require('./routes/users'),
@ -18,6 +21,12 @@ let settings = yaml.safeLoad(fsx.readFileSync('default-config.yaml'));
if (fsx.existsSync('config.yaml')) if (fsx.existsSync('config.yaml'))
Object.assign(settings, yaml.safeLoad(fsx.readFileSync('config.yaml'))); Object.assign(settings, yaml.safeLoad(fsx.readFileSync('config.yaml')));
let graphqlResolver = (request) => {
return {
time: Date.now(),
bingo: bingoRouter.graphqlResolver(request)
}
};
let app = express(); let app = express();
// view engine setup // view engine setup
@ -33,7 +42,9 @@ app.use(session({
secret: settings.sessions.secret, secret: settings.sessions.secret,
resave: false, resave: false,
saveUninitialized: true, saveUninitialized: true,
cookie: { maxAge: settings.sessions.maxAge } cookie: {
expires: 10000000
}
})); }));
app.use('/sass', compileSass({ app.use('/sass', compileSass({
root: './public/stylesheets/sass', root: './public/stylesheets/sass',
@ -46,7 +57,15 @@ app.use(express.static(path.join(__dirname, 'public')));
app.use('/', indexRouter); app.use('/', indexRouter);
app.use('/users', usersRouter); app.use('/users', usersRouter);
app.use(/\/riddle(\/.*)?/, riddleRouter); app.use(/\/riddle(\/.*)?/, riddleRouter);
app.use(/\/bingo?.*/, bingoRouter); app.use('/bingo', bingoRouter);
app.use('/graphql', graphqlHTTP(request => {
return {
schema: buildSchema(importSchema('./graphql/schema.graphql')),
rootValue: graphqlResolver(request),
context: {session: request.session},
graphiql: true
};
}));
// catch 404 and forward to error handler // catch 404 and forward to error handler
app.use(function(req, res, next) { app.use(function(req, res, next) {
@ -64,4 +83,6 @@ app.use(function(err, req, res, next) {
res.render('error'); res.render('error');
}); });
app.listen(settings.port); module.exports = app;
//app.listen(settings.port);

@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('whooshy:server');
var http = require('http');
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
var server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
var port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
debug('Listening on ' + bind);
}

@ -0,0 +1,82 @@
type BingoMutation {
# creates a game of bingo and returns the game id
createGame(words: [String!]!, size: Int = 3): BingoGame
# submit a bingo to the active game session
submitBingo: Boolean
# toggle a word (heared or not) on the sessions grid
toggleWord(word: String, base64Word: String): BingoGrid
# set the username of the current session
setUsername(username: String!): BingoUser
}
type BingoQuery {
# Returns the currently active bingo game
gameInfo(id: ID): BingoGame
# If there is a bingo in the fields.
checkBingo: Boolean
# Returns the grid of the active bingo game
activeGrid: BingoGrid
}
type BingoGame {
# the id of the bingo game
id: ID!
# the words used in the bingo game
words: [String]!
# the size of the square-grid
gridSize: Int
# an array of players active in the bingo game
players(id: ID): [BingoUser]
# the player-ids that scored a bingo
bingos: [String]!
# if the game has already finished
finished: Boolean
}
type BingoUser {
# the id of the bingo user
id: ID!
# the id of the currently active bingo game
game: ID
# the name of the user
username: String
}
type BingoGrid {
# the grid represented as string matrix
wordGrid: [[String]]!
# the grid represented as bingo field matrix
fieldGrid: [[BingoField]]!
# if there is a bingo
bingo: Boolean
}
type BingoField {
# the word contained in the bingo field
word: String
# if the word was already heared
submitted: Boolean!
base64Word: String
}

@ -0,0 +1,11 @@
# import BingoMutation from 'bingo.graphql'
# import BingoQuery from 'bingo.graphql'
type Query {
time: String
bingo: BingoQuery
}
type Mutation {
bingo: BingoMutation
}

3008
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -9,12 +9,16 @@
"cookie-parser": "~1.4.4", "cookie-parser": "~1.4.4",
"debug": "~2.6.9", "debug": "~2.6.9",
"express": "~4.16.1", "express": "~4.16.1",
"express-compile-sass": "latest",
"express-graphql": "^0.8.0",
"express-session": "latest",
"fs-extra": "^7.0.1", "fs-extra": "^7.0.1",
"graphql": "^14.3.0",
"graphql-import": "^0.7.1",
"http-errors": "~1.6.3", "http-errors": "~1.6.3",
"js-yaml": "latest",
"morgan": "~1.9.1", "morgan": "~1.9.1",
"pug": "2.0.0-beta11", "node-sass": "^4.12.0",
"express-compile-sass": "latest", "pug": "2.0.0-beta11"
"express-session": "latest",
"js-yaml": "latest"
} }
} }

@ -14,10 +14,15 @@ async function submitBingoWords() {
} }
async function submitUsername() { async function submitUsername() {
let username = document.querySelector('#username-input').value; let unameInput = document.querySelector('#username-input');
let username = unameInput.value;
let response = await postLocData({ let response = await postLocData({
username: username username: username
}); });
unameInput.value = '';
unameInput.placeholder = username;
document.querySelector('#username-form').remove();
document.querySelector('.greyover').remove();
console.log(response); console.log(response);
} }
@ -31,12 +36,15 @@ async function submitWord(word) {
let data = JSON.parse(response.data); let data = JSON.parse(response.data);
for (let row of data.fieldGrid) { for (let row of data.fieldGrid) {
for (let field of row) { for (let field of row) {
document.querySelector(`.bingo-word-panel[b-word="${field.word}"]`) document.querySelectorAll(`.bingo-word-panel[b-word="${field.base64Word}"]`).forEach(x => {
.setAttribute('b-sub', field.submitted); x.setAttribute('b-sub', field.submitted);
});
} }
} }
if (data.bingo) { if (data.bingo) {
document.querySelector('#bingo-button').setAttribute('class', ''); document.querySelector('#bingo-button').setAttribute('class', '');
} else {
document.querySelector('#bingo-button').setAttribute('class', 'hidden');
} }
} }

@ -9,14 +9,43 @@ textarea
display: block display: block
margin: 1rem margin: 1rem
border-radius: 0 border-radius: 0
height: 50% font-size: 0.8em
width: 50%
font-size: 15pt @media(max-device-width: 641px)
textarea
height: 80%
width: calc(100% - 2rem)
#words-container
width: 100%
height: 80%
@media(min-device-width: 641px)
textarea
height: 80%
width: 50%
#words-container
width: 100%
height: 88%
.number-input .number-input
width: 4rem width: 4rem
margin: 1rem margin: 1rem
#bingoheader
display: table
width: 100%
div
display: table-cell
text-align: start
.stretchDiv
text-align: end
button
max-width: calc(100% - 2rem)
padding: 0.7rem 2rem
#words-container #words-container
display: table display: table
@ -26,9 +55,18 @@ textarea
.bingo-word-panel .bingo-word-panel
@include default-element @include default-element
display: table-cell display: table-cell
padding: 3rem padding: 1rem
transition-duration: 0.3s transition-duration: 0.3s
max-width: 15rem max-width: 15rem
border-radius: 0
border-collapse: collapse
text-align: center
vertical-align: middle
span
vertical-align: middle
display: inline-block
word-break: break-word
.bingo-word-panel:hover .bingo-word-panel:hover
background-color: darken($primary, 2%) background-color: darken($primary, 2%)
@ -40,6 +78,32 @@ textarea
.bingo-word-panel[b-sub="true"] .bingo-word-panel[b-sub="true"]
background-color: forestgreen background-color: forestgreen
#bingo-button
transition-duration: 0.8s
#username-form
@include default-element
position: fixed
display: block
height: calc(50% - 1rem)
width: calc(40% - 1rem)
top: 25%
left: 30%
text-align: center
vertical-align: middle
padding: 1rem
z-index: 1000
button
cursor: pointer
input[type='text']
cursor: text
#username-form *
display: inline-block
vertical-align: middle
.popup .popup
@include default-element @include default-element
height: 5% height: 5%

@ -5,3 +5,4 @@
color: $primarySurface color: $primarySurface
border: 2px solid $primarySurface border: 2px solid $primarySurface
border-radius: $borderRadius border-radius: $borderRadius
transition-duration: 0.2s

@ -2,16 +2,39 @@
@import classes @import classes
@import mixins @import mixins
@media (min-device-width: 320px)
html
font-size: 4.5vw
@media (min-device-width: 481px)
html
font-size: 4vw
@media (min-device-width: 641px)
html
font-size: 4vw
@media (min-device-width: 961px)
html
font-size: 3vw
@media (min-device-width: 1025px)
html
font-size: 2vw
@media (min-device-width: 1281px)
html
font-size: 1.5vw
body body
background-color: $primary background-color: $primary
color: $primarySurface color: $primarySurface
font-size: 18pt
font-family: Arial, sans-serif font-family: Arial, sans-serif
button button
@include default-element @include default-element
font-size: 20pt font-size: 1.2rem
padding: 10px padding: 0.7rem
transition-duration: 0.2s transition-duration: 0.2s
button:hover button:hover
@ -23,6 +46,6 @@ button:active
input input
@include default-element @include default-element
font-size: 20pt font-size: 1.2rem
background-color: lighten($primary, 10%) background-color: lighten($primary, 10%)
padding: 9px padding: 0.7rem

@ -30,29 +30,55 @@ class BingoSession {
let id = user.id; let id = user.id;
this.users[id] = user; this.users[id] = user;
} }
/**
* Graphql endpoint
* @param args {Object} - the arguments passed on the graphql interface
* @returns {any[]|*}
*/
players(args) {
if (args.id)
return [this.users[args.id]];
else
return Object.values(this.users);
}
} }
class BingoUser { class BingoUser {
/**
* Bingo User class to store user information
*/
constructor() { constructor() {
this.id = generateBingoId(); this.id = generateBingoId();
this.game = null; this.game = null;
this.username = 'anonymous'; this.username = 'anonymous';
this.grids = {}; this.grids = {};
this.submittedWords = {};
} }
} }
class BingoWordField { class BingoWordField {
/**
* Represents a single bingo field with the word an the status.
* It also holds the base64-encoded word.
* @param word
*/
constructor(word) { constructor(word) {
this.word = word; this.word = word;
this.base64Word = Buffer.from(word).toString('base64');
this.submitted = false; this.submitted = false;
} }
} }
class BingoGrid { class BingoGrid {
/**
* Represents the bingo grid containing all the words.
* @param wordGrid
* @returns {BingoGrid}
*/
constructor(wordGrid) { constructor(wordGrid) {
this.wordGrid = wordGrid; this.wordGrid = wordGrid;
this.fieldGrid = wordGrid.map(x => x.map(y => new BingoWordField(y))); this.fieldGrid = wordGrid.map(x => x.map(y => new BingoWordField(y)));
this.bingo = false;
return this; return this;
} }
} }
@ -74,6 +100,20 @@ function shuffleArray(array) {
return array; return array;
} }
/**
* Inflates an array to a minimum Size
* @param array {Array} - the array to inflate
* @param minSize {Number} - the minimum size that the array needs to have
* @returns {Array}
*/
function inflateArray(array, minSize) {
let resultArray = array;
let iterations = Math.ceil(minSize/array.length);
for (let i = 0; i < iterations; i++)
resultArray = [...resultArray, ...resultArray];
return resultArray
}
/** /**
* Generates an id for a subreddit download. * Generates an id for a subreddit download.
* @returns {string} * @returns {string}
@ -89,7 +129,7 @@ function generateBingoId() {
* @returns {BingoGrid} * @returns {BingoGrid}
*/ */
function generateWordGrid(dimensions, words) { function generateWordGrid(dimensions, words) {
let shuffledWords = shuffleArray(words); let shuffledWords = shuffleArray(inflateArray(words, dimensions[0]*dimensions[1]));
let grid = []; let grid = [];
for (let x = 0; x < dimensions[1]; x++) { for (let x = 0; x < dimensions[1]; x++) {
grid[x] = []; grid[x] = [];
@ -102,19 +142,17 @@ function generateWordGrid(dimensions, words) {
/** /**
* Sets the submitted parameter of the words in the bingo grid that match to true. * Sets the submitted parameter of the words in the bingo grid that match to true.
* @param word {String} * @param base64Word {String} - base64 encoded bingo word
* @param bingoGrid {BingoGrid} * @param bingoGrid {BingoGrid} - the grid where the words are stored
* @returns {boolean} * @returns {boolean}
*/ */
function submitWord(word, bingoGrid) { function toggleHeared(base64Word, bingoGrid) {
let results = bingoGrid.fieldGrid.find(x => x.find(y => (y.word === word))).find(x => x.word === word); for (let row of bingoGrid.fieldGrid)
for (let field of row)
if (results) { if (base64Word === field.base64Word)
(results instanceof Array)? results.forEach(x => {x.submitted = true}): results.submitted = true; field.submitted = !field.submitted;
checkBingo(bingoGrid); checkBingo(bingoGrid);
return true; return true;
}
return false;
} }
/** /**
@ -146,8 +184,10 @@ function checkBingo(bingoGrid) {
bingoCheck = true; bingoCheck = true;
for (let field of row) for (let field of row)
bingoCheck = field && bingoCheck; bingoCheck = field && bingoCheck;
if (bingoCheck) if (bingoCheck) {
break; bingoGrid.bingo = true;
return true;
}
} }
if (bingoCheck) { if (bingoCheck) {
bingoGrid.bingo = true; bingoGrid.bingo = true;
@ -159,13 +199,16 @@ function checkBingo(bingoGrid) {
bingoCheck = true; bingoCheck = true;
for (let j = 0; j < fg.length; j++) for (let j = 0; j < fg.length; j++)
bingoCheck = fg[j][i] && bingoCheck; bingoCheck = fg[j][i] && bingoCheck;
if (bingoCheck) if (bingoCheck) {
break; bingoGrid.bingo = true;
return true;
}
} }
if (bingoCheck) { if (bingoCheck) {
bingoGrid.bingo = true; bingoGrid.bingo = true;
return true; return true;
} }
bingoGrid.bingo = false;
return false; return false;
} }
@ -191,7 +234,7 @@ router.get('/', (req, res) => {
if (!bingoUser.grids[gameId]) { if (!bingoUser.grids[gameId]) {
bingoUser.grids[gameId] = generateWordGrid([bingoSession.gridSize, bingoSession.gridSize], bingoSession.words); bingoUser.grids[gameId] = generateWordGrid([bingoSession.gridSize, bingoSession.gridSize], bingoSession.words);
} }
res.render('bingo/bingo-game', {grid: bingoUser.grids[gameId].wordGrid, username: bingoUser.username}); res.render('bingo/bingo-game', {grid: bingoUser.grids[gameId].fieldGrid, username: bingoUser.username});
} else { } else {
res.render('bingo/bingo-submit'); res.render('bingo/bingo-submit');
} }
@ -226,12 +269,9 @@ router.post('/', (req, res) => {
} else if (data.game) { } else if (data.game) {
res.send(bingoSessions[data.game]); res.send(bingoSessions[data.game]);
} else if (data.bingoWord) { } else if (data.bingoWord) {
if (!bingoUser.submittedWords[gameId])
bingoUser.submittedWords[gameId] = [];
bingoUser.submittedWords[gameId].push(data.bingoWord);
console.log(typeof bingoUser.grids[gameId]); console.log(typeof bingoUser.grids[gameId]);
if (bingoUser.grids[gameId]) if (bingoUser.grids[gameId])
submitWord(data.bingoWord, bingoUser.grids[gameId]); toggleHeared(data.bingoWord, bingoUser.grids[gameId]);
res.send(bingoUser.grids[gameId]); res.send(bingoUser.grids[gameId]);
} else if (data.bingo) { } else if (data.bingo) {
if (checkBingo(bingoUser.grids[gameId])) { if (checkBingo(bingoUser.grids[gameId])) {
@ -239,7 +279,7 @@ router.post('/', (req, res) => {
bingoSession.bingos.push(bingoUser.id); bingoSession.bingos.push(bingoUser.id);
bingoSession.finished = true; bingoSession.finished = true;
setTimeout(() => { // delete the finished game after five minutes setTimeout(() => { // delete the finished game after five minutes
delete bingoSessions[game.id]; delete bingoSessions[gameId];
}, 360000); }, 360000);
res.send(bingoSession); res.send(bingoSession);
} else { } else {
@ -256,4 +296,68 @@ router.post('/', (req, res) => {
} }
}); });
router.graphqlResolver = (req) => {
let bingoUser = req.session.bingoUser || new BingoUser();
let gameId = req.query.game || bingoUser.game || null;
let bingoSession = bingoSessions[gameId];
return {
// queries
gameInfo: (args) => {
if (args.id)
return bingoSessions[args.id];
else
return bingoSession;
},
checkBingo: (args) => {
return checkBingo(bingoUser.grids[gameId])
},
activeGrid: (args) => {
return bingoUser.grids[gameId];
},
// mutation
createGame: (args) => {
let words = args.words;
let size = args.size;
let game = new BingoSession(words, size);
bingoSessions[game.id] = game;
setTimeout(() => { // delete the game after one day
delete bingoSessions[game.id];
}, 86400000);
return game;
},
submitBingo: (args) => {
if (checkBingo(bingoUser.grids[gameId])) {
if (!bingoSession.bingos.includes(bingoUser.id))
bingoSession.bingos.push(bingoUser.id);
bingoSession.finished = true;
setTimeout(() => { // delete the finished game after five minutes
delete bingoSessions[gameId];
}, 360000);
return true;
} else {
return false;
}
},
toggleWord: (args) => {
if (args.word || args.base64Word) {
args.base64Word = args.base64Word || Buffer.from(args.word).toString('base-64');
if (bingoUser.grids[gameId])
toggleHeared(args.base64Word, bingoUser.grids[gameId]);
return bingoUser.grids[gameId];
}
},
setUsername: (args) => {
if (args.username) {
bingoUser.username = args.username;
bingoSession.addUser(bingoUser);
return bingoUser;
}
}
};
};
module.exports = router; module.exports = router;

@ -1,13 +1,15 @@
include bingo-layout include bingo-layout
block content block content
div(id='username-form') if username === 'anonymous'
input(type='text', id='username-input', placeholder='username', value=username) div(class='greyover')
button(onclick='submitUsername()') Set Username div(id='username-form')
button(id='bingo-button' onclick='submitBingo()', class='hidden') Bingo! input(type='text', id='username-input', placeholder=username)
button(onclick='submitUsername()') Set Username
div(id='words-container') div(id='words-container')
each val in grid each val in grid
div(class='bingo-word-row') div(class='bingo-word-row')
each word in val each field in val
div(class='bingo-word-panel', onclick=`submitWord('${word}')`, b-word=word, b-sub='false') div(class='bingo-word-panel', onclick=`submitWord('${field.base64Word}')`, b-word=field.base64Word, b-sub='false')
span= word span= field.word
button(id='bingo-button' onclick='submitBingo()', class='hidden') Bingo!

@ -3,6 +3,5 @@ html
include ../includes/head include ../includes/head
script(type='text/javascript', src='/javascripts/bingo-web.js') script(type='text/javascript', src='/javascripts/bingo-web.js')
link(rel='stylesheet', href='/sass/bingo/style.sass') link(rel='stylesheet', href='/sass/bingo/style.sass')
body body
block content block content

@ -2,8 +2,11 @@ extends bingo-layout
block content block content
div(id='bingoform') div(id='bingoform')
input(type='number', id='bingo-grid-size', class='number-input', value=3, min=1, max=8) div(id='bingoheader')
span x div
span(id='bingo-grid-y', class='number-input') 3 input(type='number', id='bingo-grid-size', class='number-input', value=3, min=1, max=8)
button(onclick='submitBingoWords()') Submit span x
span(id='bingo-grid-y', class='number-input') 3
div(class='stretchDiv')
button(onclick='submitBingoWords()') Submit
textarea(id='bingo-textarea', placeholder='Bingo Words') textarea(id='bingo-textarea', placeholder='Bingo Words')
Loading…
Cancel
Save