Serveral improvements and features

- added notifications on inactive window
- changed max grid size to 5
- shortened phrases that exceed 200 characters
- improved sql performance
- fixed join issues
pull/15/head
Trivernis 6 years ago
parent 9d806a7935
commit c91712d367

@ -29,14 +29,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- bingo status bar
- bingo chat commands
- cookie info dialog
- chat and round notifications
## Changed
### Changed
- changed export of `app.js` to the asynchronous init function that returns the app object
- `bin/www` now calls the init function of `app.js`
- graphql bingo api
- bingo frontend
- moved some bingo pug files to ./bingo/includes/
- style of `code`
- font to Ubuntu and Ubuntu Monospace
- grid size limit to 5
- improved sql grid word insertion query
### Removed
@ -54,3 +59,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- backend now returns precise error messages
- setting words won't result in deleting all of them and resaving
- words can now only be set when no round is active
- username allowing emojis
- username can have a length of 0 (now at least 1 character)
- mozilla didn't have a fancy scrollbar (no webkit browser)
- kicked users can join lobby on active round
- users can't join lobby on active round
- server crash on too big phrases

@ -17,6 +17,7 @@ const createError = require('http-errors'),
indexRouter = require('./routes/index'),
usersRouter = require('./routes/users'),
riddleRouter = require('./routes/riddle'),
changelogRouter = require('./routes/changelog'),
bingoRouter = require('./routes/bingo');
@ -72,6 +73,7 @@ async function init() {
//app.use('/users', usersRouter);
//app.use(/\/riddle(\/.*)?/, riddleRouter);
app.use('/bingo', bingoRouter);
app.use('/changelog', changelogRouter);
app.use('/graphql', graphqlHTTP(async (request, response) => {
return await {
schema: buildSchema(importSchema('./graphql/schema.graphql')),

@ -1,4 +1,5 @@
/* eslint-disable no-unused-vars, no-undef */
/**
* Returns the value of the url-param 'g'
* @returns {string}
@ -9,7 +10,6 @@ function getLobbyParam() {
return matches[1];
else
return '';
}
/**
@ -24,6 +24,21 @@ function getRoundParam() {
return '';
}
/**
* Spawns a notification when the window is inactive (hidden).
* @param body
* @param title
*/
function spawnNotification(body, title) {
if (Notification.permission !== 'denied' && document[getHiddenNames().hidden]) {
let options = {
body: body,
icon: '/favicon.ico'
};
let n = new Notification(title, options);
}
}
/**
* Submits the value of the username-input to set the username.
* @returns {Promise<Boolean>}
@ -46,7 +61,9 @@ async function submitUsername() {
* @returns {Promise<boolean>}
*/
async function setUsername(username) {
let response = await postGraphqlQuery(`
let uname = username.substring(0, 30).replace(/[^\w- ;[\]]/g, '');
if (uname.length === username.length) {
let response = await postGraphqlQuery(`
mutation($username:String!) {
bingo {
setUsername(username: $username) {
@ -54,13 +71,19 @@ async function setUsername(username) {
username
}
}
}`, {username: username});
if (response.status === 200) {
return true;
}`, {username: username}, '/graphql?g='+getLobbyParam());
if (response.status === 200) {
return response.data.bingo.setUsername.username;
} else {
if (response.errors)
showError(response.errors[0].message);
else
showError(`Failed to submit username.`);
console.error(response);
return false;
}
} else {
showError(`Failed to submit username. Error: ${response.errors.join(', ')}`);
console.error(response);
return false;
showError('Your username contains illegal characters.');
}
}
@ -79,11 +102,16 @@ async function ping() {
}
/**
* TODO: real join logic
* Joins a lobby or says to create one if none is found
* @returns {Promise<void>}
*/
async function joinLobby() {
await submitUsername();
window.location.reload();
if (getLobbyParam()) {
if (await submitUsername())
window.location.reload();
} else {
showError('No lobby found. Please create one.');
}
}
/**
@ -91,22 +119,24 @@ async function joinLobby() {
* @returns {Promise<boolean>}
*/
async function createLobby() {
let response = await postGraphqlQuery(`
mutation {
bingo {
createLobby {
id
if (await submitUsername()) {
let response = await postGraphqlQuery(`
mutation {
bingo {
createLobby {
id
}
}
}
`);
if (response.status === 200 && response.data.bingo.createLobby) {
insertParam('g', response.data.bingo.createLobby.id);
return true;
} else {
showError('Failed to create Lobby. HTTP ERROR: ' + response.status);
console.error(response);
return false;
}
}
}
`);
if (response.status === 200 && response.data.bingo.createLobby) {
insertParam('g', response.data.bingo.createLobby.id);
return true;
} else {
showError('Failed to create Lobby. HTTP ERROR: ' + response.status);
console.error(response);
return false;
}
}
@ -197,8 +227,8 @@ async function executeCommand(message) {
break;
case '/username':
if (command[2]) {
await setUsername(command[2]);
reply(`Your username is <b>${command[2]}</b> now.`)
let uname = await setUsername(command[2]);
reply(`Your username is <b>${uname}</b> now.`);
} else {
reply('You need to provide a username');
}
@ -278,7 +308,10 @@ async function setLobbySettings(words, gridSize) {
return response.data.bingo.mutateLobby.setWords.words;
} else {
console.error(response);
showError('Error when setting lobby words.');
if (response.errors)
showError(response.errors[0].message);
else
showError('Error when submitting lobby settings.');
}
}
@ -289,25 +322,31 @@ async function setLobbySettings(words, gridSize) {
async function startRound() {
let textinput = document.querySelector('#input-bingo-words');
let words = getLobbyWords();
let gridSize = document.querySelector('#input-grid-size').value || 3;
let resultWords = await setLobbySettings(words, gridSize);
textinput.value = resultWords.map(x => x.content).join('\n');
let response = await postGraphqlQuery(`
mutation($lobbyId:ID!){
bingo {
mutateLobby(id:$lobbyId) {
startRound {
id
}
}
}
}`, {lobbyId: getLobbyParam()});
if (words.length > 0) {
let gridSize = document.querySelector('#input-grid-size').value || 3;
let resultWords = await setLobbySettings(words, gridSize);
if (resultWords) {
textinput.value = resultWords.map(x => x.content).join('\n');
let response = await postGraphqlQuery(`
mutation($lobbyId:ID!){
bingo {
mutateLobby(id:$lobbyId) {
startRound {
id
}
}
}
}`, {lobbyId: getLobbyParam()});
if (response.status === 200) {
insertParam('r', response.data.bingo.mutateLobby.startRound.id);
if (response.status === 200) {
insertParam('r', response.data.bingo.mutateLobby.startRound.id);
} else {
console.error(response);
showError('Error when starting round.');
}
}
} else {
console.error(response);
showError('Error when starting round.');
throw new Error('No words provided.');
}
}
@ -429,11 +468,9 @@ function displayWinner(roundInfo) {
<button id="button-lobbyreturn" onclick="window.location.reload()">Return to Lobby!</button>
`;
greyoverDiv.setAttribute('class', 'greyover');
//winnerDiv.onclick = () => {
// window.location.reload();
//};
document.body.append(greyoverDiv);
document.body.appendChild(winnerDiv);
spawnNotification(`${name} has won!`, 'Bingo');
}
/**
@ -471,7 +508,7 @@ async function statusWrap(func) {
/**
* Loads information about the rounds winner and the round stats.
* @returns {Promise<void>}
* @returns {Promise<boolean>}
*/
async function loadWinnerInfo() {
let response = await postGraphqlQuery(`
@ -505,8 +542,9 @@ async function loadWinnerInfo() {
/**
* Adds a message to the chat
* @param messageObject {Object} - the message object returned by graphql
* @param player {Number} - the id of the player
*/
function addChatMessage(messageObject) {
function addChatMessage(messageObject, player) {
let msgSpan = document.createElement('span');
msgSpan.setAttribute('class', 'chatMessage');
msgSpan.setAttribute('msg-type', messageObject.type);
@ -520,6 +558,8 @@ function addChatMessage(messageObject) {
<span class="chatMessageContent ${messageObject.type}">${messageObject.htmlContent}</span>`;
}
if (messageObject.author && messageObject.author.id !== player)
spawnNotification(messageObject.content, messageObject.author.username);
let chatContent = document.querySelector('#chat-content');
chatContent.appendChild(msgSpan);
chatContent.scrollTop = chatContent.scrollHeight; // auto-scroll to bottom
@ -552,12 +592,17 @@ async function refreshChat() {
let response = await postGraphqlQuery(`
query($lobbyId:ID!){
bingo {
player {
id
}
lobby(id:$lobbyId) {
messages {
id
type
htmlContent
content
author {
id
username
}
}
@ -568,7 +613,7 @@ async function refreshChat() {
let messages = response.data.bingo.lobby.messages;
for (let message of messages)
if (!document.querySelector(`.chatMessage[msg-id="${message.id}"]`))
addChatMessage(message);
addChatMessage(message, response.data.bingo.player.id);
} else {
showError('Failed to refresh messages');
console.error(response);
@ -678,6 +723,7 @@ async function refreshLobby() {
}
currentRound {
id
status
}
words {
content
@ -695,8 +741,10 @@ async function refreshLobby() {
wordContainer.innerHTML = `<span class="bingoWord">
${response.data.bingo.lobby.words.map(x => x.content).join('</span><span class="bingoWord">')}</span>`;
if (currentRound && currentRound.id && Number(currentRound.id) !== Number(getRoundParam()))
if (currentRound && currentRound.status === 'ACTIVE' && Number(currentRound.id) !== Number(getRoundParam())) {
insertParam('r', currentRound.id);
spawnNotification('The round started!', 'Bingo');
}
} else {
showError('Failed to refresh lobby');
@ -771,3 +819,14 @@ window.addEventListener("keydown", async (e) => {
}
}
}, false);
window.onload = async () => {
if ("Notification" in window)
if (Notification.permission !== 'denied') {
try {
await Notification.requestPermission();
} catch (err) {
showError(err.message);
}
}
};

@ -128,6 +128,10 @@ async function indicateStatus(func, indicatorSelector) {
}
}
/**
* posts to accept cookies.
* @returns {Promise<void>}
*/
async function acceptCookies() {
await postGraphqlQuery(`
mutation {
@ -135,3 +139,25 @@ async function acceptCookies() {
}`);
document.querySelector('#cookie-container').remove();
}
/**
* Gets the names for the windows hidden and visibility change events and properties
* @returns {{hidden: string, visibilityChange: string}}
*/
function getHiddenNames() {
let hidden, visibilityChange;
if (typeof document.hidden !== "undefined") { // Opera 12.10 and Firefox 18 and later support
hidden = "hidden";
visibilityChange = "visibilitychange";
} else if (typeof document.msHidden !== "undefined") {
hidden = "msHidden";
visibilityChange = "msvisibilitychange";
} else if (typeof document.webkitHidden !== "undefined") {
hidden = "webkitHidden";
visibilityChange = "webkitvisibilitychange";
}
return {
hidden: hidden,
visibilityChange: visibilityChange
};
}

@ -44,9 +44,11 @@
border-radius: 0
color: $primarySurface
border: 1px solid $inactive
position: relative
.playerWins
margin-left: 1em
position: absolute
right: 0
.kickPlayerButton
color: $inactive
@ -183,13 +185,18 @@
#statusbar
display: grid
grid-template: 100% / 1rem calc(80% - 1rem) 20%
grid-template: 100% 0 / 1rem calc(75% - 1rem) 25%
background-color: darken($primary, 5%)
margin: 0.5rem 0 0 0
padding: 0.25rem
vertical-align: middle
font-size: 0.8em
text-align: start
position: absolute
height: 1rem
bottom: 0
left: 0
width: calc(100% - 0.5rem)
#status-indicator
height: 1rem
@ -204,6 +211,8 @@
color: $errorText
#container-info
@include gridPosition(1, 2, 3, 4)
text-align: left
width: 100%
height: 100%
margin: auto
@ -237,9 +246,6 @@
button
width: 100%
#statusbar
@include gridPosition(5, 6, 1, 4)
#container-bingo-lobby
@include fillWindow
overflow: hidden
@ -264,9 +270,6 @@
@include gridPosition(3, 4, 4, 5)
background-color: lighten($primary, 5%)
#statusbar
@include gridPosition(4, 5, 1, 6)
#container-bingo-round
@include fillWindow
overflow: hidden
@ -287,6 +290,3 @@
#container-bingo-button
@include gridPosition(1, 2, 1, 2)
#statusbar
@include gridPosition(4, 5, 1, 3)

@ -13,9 +13,11 @@
#info
@include gridPosition(1, 2, 2, 3)
margin: auto
text-align: center
h1, p
h1, p, a
margin: auto
text-align: center
#bingo-button
background-color: $secondary

@ -29,7 +29,8 @@
body
background-color: $primary
color: $primarySurface
font-family: Arial, sans-serif
font-family: $fontRegular
scrollbar-color: $secondary lighten($primary, 5)
button
@include default-element
@ -52,6 +53,7 @@ input
transition-duration: 0.2s
font-size: 1.2rem
padding: 0.7rem
font-family: $fontRegular
input:focus
background-color: lighten($primary, 15%)
@ -64,9 +66,10 @@ textarea
background-color: lighten($primary, 15%)
color: $primarySurface
resize: none
font-family: $fontRegular
a
color: $secondary
color: mix($secondary, $primarySurface, 75%)
mark
background-color: $secondary
@ -75,6 +78,12 @@ mark
mark > a
color: white
code
background-color: transparentize(darken($primary, 10%), 0.5)
border-radius: 0.25em
font-family: $fontCode
padding: 0 0.25em
::-webkit-scrollbar
width: 12px
height: 12px

@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css?family=Ubuntu|Ubuntu+Mono&display=swap')
$primary: #223
$primarySurface: white
$secondary: teal
@ -7,3 +9,5 @@ $error: #a00
$errorText: #f44
$success: #0a0
$pending: #aa0
$fontRegular: 'Ubuntu', Arial, sans-serif
$fontCode: 'Ubuntu Mono', monospace

@ -12,7 +12,6 @@ const express = require('express'),
globals = require('../lib/globals');
let pgPool = globals.pgPool;
let playerNames = utils.getFileLines('./misc/usernames.txt').filter(x => (x && x.length > 0));
/**
* Class to manage the bingo data in the database.
@ -333,6 +332,25 @@ class BingoDataManager {
return await this._queryFirstResult(this.queries.addWordToGrid.sql, [gridId, wordId, row, column]);
}
/**
* Adds words to the grid
* @param gridId {Number} - the id of the grid
* @param words {Array<{wordId: Number, row: Number, column:Number}>}
* @returns {Promise<void>}
*/
async addWordsToGrid(gridId, words) {
let valueSql = buildSqlParameters(4, words.length, 0);
let values = [];
for (let word of words) {
values.push(gridId);
values.push(word.wordId);
values.push(word.row);
values.push(word.column);
}
return await this._queryFirstResult(
this.queries.addWordToGridStrip.sql + valueSql + ' RETURNING *', values);
}
/**
* Returns all words in the grid with location
* @param gridId {Number} - the id of the grid
@ -707,6 +725,22 @@ class PlayerWrapper {
return new GridWrapper(result.id);
}
/**
* Returns if the user has a valid grid.
* @param lobbyId
* @returns {Promise<boolean>}
*/
async hasGrid(lobbyId) {
let grid = await this.grid({lobbyId: lobbyId});
if (grid) {
let fields = await grid.fields();
let lobbyWrapper = new LobbyWrapper(lobbyId);
return fields.length === (await lobbyWrapper.gridSize()) ** 2;
} else {
return false;
}
}
/**
* Returns the username of the player
* @returns {Promise<String|null>}
@ -1006,10 +1040,11 @@ class LobbyWrapper {
let gridId = (await bdm.addGrid(this.id, player.id, currentRound)).id;
let gridContent = generateWordGrid(this.grid_size, words);
let gridWords = [];
for (let i = 0; i < gridContent.length; i++)
for (let j = 0; j < gridContent[i].length; j++)
// eslint-disable-next-line no-await-in-loop
await bdm.addWordToGrid(gridId, gridContent[i][j].id, i, j);
gridWords.push({wordId: gridContent[i][j].id, row: i, column: j});
await bdm.addWordsToGrid(gridId, gridWords);
}
}
@ -1025,6 +1060,7 @@ class LobbyWrapper {
await currentRound.setFinished();
await this._createRound();
await this._createGrids();
await this.setRoundStatus('ACTIVE');
}
}
@ -1073,6 +1109,7 @@ class LobbyWrapper {
*/
async setWords(words) {
if (words.length > 0 && !await this.roundActive()) {
words = words.map(x => x.substring(0, 200));
let {newWords, removedWords} = await this._filterWords(words);
for (let word of newWords)
await this.addWord(word);
@ -1156,6 +1193,23 @@ class LobbyWrapper {
}
}
/**
* Returns the parameterized value sql for inserting.
* @param columnCount
* @param rowCount
* @param [offset]
* @returns {string}
*/
function buildSqlParameters(columnCount, rowCount, offset) {
let sql = '';
for (let i = 0; i < rowCount; i++) {
sql += '(';
for (let j = 0; j < columnCount; j++)
sql += `$${(i*columnCount)+j+1+offset},`;
sql = sql.replace(/,$/, '') + '),';
}
return sql.replace(/,$/, '');
}
/**
* Replaces tag signs with html-escaped signs.
@ -1341,6 +1395,20 @@ async function getGridData(lobbyId, playerId) {
return {fields: fieldGrid, bingo: await grid.bingo()};
}
/**
* Returns resolved message data.
* @param lobbyId
* @returns {Promise<Array>}
*/
async function getMessageData(lobbyId) {
let lobbyWrapper = new LobbyWrapper(lobbyId);
let messages = await lobbyWrapper.messages({limit: 20});
let msgReturn = [];
for (let message of messages)
msgReturn.push(Object.assign(message, {username: await message.author.username()}));
return msgReturn;
}
// -- Router stuff
@ -1361,10 +1429,11 @@ router.get('/', async (req, res) => {
let info = req.session.acceptedCookies? null: globals.cookieInfo;
let lobbyWrapper = new LobbyWrapper(req.query.g);
let playerWrapper = new PlayerWrapper(playerId);
if (playerId && await playerWrapper.exists() && req.query.g && await lobbyWrapper.exists()) {
let lobbyId = req.query.g;
if (!(await lobbyWrapper.roundActive())) {
if (!(await lobbyWrapper.roundActive() && await playerWrapper.hasGrid(lobbyId))) {
if (!await lobbyWrapper.hasPlayer(playerId))
await lobbyWrapper.addPlayer(playerId);
let playerData = await getPlayerData(lobbyWrapper);
@ -1377,10 +1446,11 @@ router.get('/', async (req, res) => {
words: words,
wordString: words.join('\n'),
gridSize: await lobbyWrapper.gridSize(),
info: info
info: info,
messages: await getMessageData(lobbyId)
});
} else {
if (await lobbyWrapper.hasPlayer(playerId)) {
if (await lobbyWrapper.hasPlayer(playerId) && await playerWrapper.hasGrid(lobbyId)) {
let playerData = await getPlayerData(lobbyWrapper);
let grid = await getGridData(lobbyId, playerId);
let admin = await lobbyWrapper.admin();
@ -1389,21 +1459,11 @@ router.get('/', async (req, res) => {
grid: grid,
isAdmin: (playerId === admin.id),
adminId: admin.id,
info: info
info: info,
messages: await getMessageData(lobbyId)
});
} else {
let playerData = await getPlayerData(lobbyWrapper);
let admin = await lobbyWrapper.admin();
let words = await getWordsData(lobbyWrapper);
res.render('bingo/bingo-lobby', {
players: playerData,
isAdmin: (playerId === admin.id),
adminId: admin.id,
words: words,
wordString: words.join('\n'),
gridSize: await lobbyWrapper.gridSize(),
info: info
});
res.redirect('/bingo');
}
}
} else {
@ -1436,16 +1496,24 @@ router.graphqlResolver = async (req, res) => {
},
// mutations
setUsername: async ({username}) => {
username = replaceTagSigns(username.substring(0, 30)); // only allow 30 characters
let playerWrapper = new PlayerWrapper(playerId);
username = replaceTagSigns(username.substring(0, 30)).replace(/[^\w- ;[\]]/g, ''); // only allow 30 characters
if (username.length > 0) {
let playerWrapper = new PlayerWrapper(playerId);
if (!playerId || !(await playerWrapper.exists())) {
req.session.bingoPlayerId = (await bdm.addPlayer(username)).id;
playerId = req.session.bingoPlayerId;
if (!playerId || !(await playerWrapper.exists())) {
req.session.bingoPlayerId = (await bdm.addPlayer(username)).id;
playerId = req.session.bingoPlayerId;
} else {
let oldName = await playerWrapper.username();
await bdm.updatePlayerUsername(playerId, username);
if (req.query.g)
await bdm.addInfoMessage(req.query.g, `${oldName} changed username to ${username}`);
}
return new PlayerWrapper(playerId);
} else {
await bdm.updatePlayerUsername(playerId, username);
res.status(400);
return new GraphQLError('Username too short!');
}
return new PlayerWrapper(playerId);
},
createLobby: async({gridSize}) => {
if (playerId)
@ -1508,13 +1576,18 @@ router.graphqlResolver = async (req, res) => {
}
},
setGridSize: async ({gridSize}) => {
let admin = await lobbyWrapper.admin();
if (admin.id === playerId) {
await lobbyWrapper.setGridSize(gridSize);
return lobbyWrapper;
if (gridSize > 0 && gridSize < 6) {
let admin = await lobbyWrapper.admin();
if (admin.id === playerId) {
await lobbyWrapper.setGridSize(gridSize);
return lobbyWrapper;
} else {
res.status(403);
return new GraphQLError('You are not an admin');
}
} else {
res.status(403);
return new GraphQLError('You are not an admin');
res.status(400);
return new GraphQLError('Grid size too big!');
}
},
setWords: async({words}) => {

@ -0,0 +1,17 @@
const express = require('express'),
router = express.Router(),
globals = require('../lib/globals'),
fsx = require('fs-extra'),
mdEmoji = require('markdown-it-emoji'),
md = require('markdown-it')()
.use(mdEmoji);
let changelog = fsx.readFileSync('CHANGELOG.md', 'utf-8');
/* GET home page. */
router.get('/', (req, res) => {
let info = req.session.acceptedCookies? null: globals.cookieInfo;
res.render('changelog/changes', { changelog: md.render(changelog), info: info});
});
module.exports = router;

@ -6,7 +6,7 @@ const globals = require('../lib/globals');
/* GET home page. */
router.get('/', function(req, res) {
let info = req.session.acceptedCookies? null: globals.cookieInfo;
res.render('index', { title: 'Trivernis.net', info: info});
res.render('index', { title: 'Trivernis.net', info: info, contact: 'mailto:trivernis@gmail.com'});
});
module.exports = router;

@ -45,7 +45,7 @@ CREATE TABLE IF NOT EXISTS bingo.rounds (
id serial UNIQUE PRIMARY KEY,
start timestamp DEFAULT NOW(),
finish timestamp,
status varchar(8) DEFAULT 'ACTIVE',
status varchar(8) DEFAULT 'BUILDING',
lobby_id serial references bingo.lobbys(id) ON DELETE CASCADE,
winner integer
);

@ -218,6 +218,12 @@ getGridInfo:
addWordToGrid:
sql: INSERT INTO bingo.grid_words (grid_id, word_id, grid_row, grid_column) VALUES ($1, $2, $3, $4) RETURNING *;
# inserts grid-word connections into the database
# params:
# ! need to be set in the sql
addWordToGridStrip:
sql: INSERT INTO bingo.grid_words (grid_id, word_id, grid_row, grid_column) VALUES
# sets a bingo field to submitted = not submitted
# params:
# - {Number} - the id of the grid

@ -1,6 +1,11 @@
div(id='container-chat')
style(id='js-style')
div(id='chat-content')
for message in messages
span.chatMessage(type=message.type msg-id=message.id)
if message.type === 'USER'
span.chatUsername= `${message.username}: `
span(class=`chatMessageContent ${message.type}`)!= message.htmlContent
input(
id='chat-input'
type='text'

@ -2,6 +2,10 @@ div(id='statusbar')
div(id='status-indicator' class='statusIndicator' status='idle')
span(id='error-message')
span(id='container-info')
| please report bugs
a(href='https://github.com/Trivernis/whooshy/issues') Bug/Feature
|
a(href='https://github.com/Trivernis/whooshy/issues') here
|
span |
|
|
a(href='mailto:trivernis@gmail.com') Contact

@ -0,0 +1,6 @@
html
head
include ../includes/head
body
include ../includes/info-container
div(id='changelog')!= changelog

@ -5,6 +5,7 @@ block content
div(id='info')
h1= title
p Welcome to #{title}
a(href=contact) Contact
button(id='bingo-button' onclick='window.location.href="/bingo"') Bingo
include includes/info-container

Loading…
Cancel
Save