Compare commits

...

16 Commits
main ... dev

35 changed files with 2039 additions and 346 deletions

3
TODO Normal file
View File

@ -0,0 +1,3 @@
- Add expert mode with typing autocompletion
- Generate a quizz of 10 question
- Add a more local species selector

16
app.js
View File

@ -1,4 +1,8 @@
require('dotenv').config();
const redisHost = process.env.REDIS_HOST ? process.env.REDIS_HOST : 'localhost';
const redisPort = process.env.REDIS_PORT ? process.env.REDIS_PORT : 6379;
const createError = require('http-errors');
const express = require('express');
const session = require('express-session');
@ -34,11 +38,11 @@ const sess = {
cookie: { secure: false }
}
if (app.get('env') === 'production') {
app.set('trust proxy', 1); // trust first proxy
sess.cookie.secure = true; // serve secure cookies
sess.store = new RedisStore({ client: redisClient });
}
// if (app.get('env') === 'production') {
// app.set('trust proxy', 1); // trust first proxy
// sess.cookie.secure = true; // serve secure cookies
// sess.store = new RedisStore({ host: redisHost, port: redisPort, client: redisClient });
// }
app.use(session(sess));
@ -70,7 +74,7 @@ app.use('/dist/leaflet', express.static('node_modules/leaflet/dist'));
app.use('/dist/feather', express.static('node_modules/feather-icons/dist'));
app.use('/dist/axios', express.static('node_modules/axios/dist'));
app.use('/api/0', apiRouter);
app.use('/api/', apiRouter);
const csrfProtection = csrf({ cookie: true });
app.use(csrfProtection);

View File

@ -3,32 +3,60 @@ const debug = require('debug')('soundbirder:api');
const debugLocale = require('debug')('soundbirder:locale');
const debugResponses = require('debug')('soundbirder:api:responses');
const quizzController = require('./quizz');
const { getRegion } = require('./region');
const QUIZZ_SIZE = process.env.QUIZZ_SIZE ? process.env.QUIZZ_SIZE : 5;
function getHome(req, res) {
res.render('api', {
title: "SoundBirder api",
version: 0
});
async function region(req, res) {
let {lat, lon} = req.query;
lat = parseFloat(lat);
lon = parseFloat(lon);
const region = await getRegion(lat, lon);
res.json({
region
})
}
function quizz(req, res) {
debug('Generating quizz');
const { lat, lng } = req.query;
debug(`Coordinates: ${lat}, ${lng}`);
async function question(req, res) {
debug('Generating question');
const { region } = req.query;
req.session.region = region;
const locale = req.i18n.locale;
debugLocale("Locale:", locale);
quizzController.generateQuizz({ lat, lng }, locale, QUIZZ_SIZE)
quizzController.getQuestionCached(region, locale, QUIZZ_SIZE)
.then(({ species, answer, audio }) => {
req.session.answer = answer;
res.json({ species, audio });
res.json({ species, audio }).send();
debug("Quizz sent");
quizzController.cacheQuestion(); // Prepare the next question in advance.
debug("New quizz cached");
})
.catch(error => {
debug("Faced error while generating quizz");
res.json({ error });
throw error;
debug(error)
});
}
async function quizz(req, res) {
const { region } = req.query;
req.session.region = region;
if (region == undefined) {
res.status(400).json({
error: 'No region specified',
})
}
const locale = req.i18n.locale;
if (locale == undefined) {
res.status(400).json(
{
error: 'No locale specified'
}
)
}
quizzController.generateQuizz(region, locale, 10)
.then(({ questions, answers, propositions }) => {
res.json({questions, answers, propositions }).send();
});
}
@ -67,10 +95,11 @@ function check(req, res) {
const game = {
check,
quizz
question,
quizz,
}
module.exports = {
getHome,
game
game,
region
}

View File

@ -8,7 +8,7 @@ function indexPage(req, res) {
function loginPage(req, res) {
res.render('auth/login', {
title: req.i18n.__("SoundBirder - Login"),
title: req.i18n.__('SoundBirder - Login'),
locale: req.i18n.locale
});
}
@ -23,7 +23,7 @@ function logout(req, res) {
function registerPage(req, res) {
res.render('auth/register', {
title: req.i18n.__("SoundBirder - Register"),
title: req.i18n.__('SoundBirder - Register'),
locale: req.i18n.locale
});
}

View File

@ -6,10 +6,24 @@ function cacheResponse(request, response) {
redisClient.set(request, JSON.stringify(response));
}
async function getCached(request) {
const cached = await redisClient.get(request);
let cached = await redisClient.pget(request);
if (cached) {
debug("Cached", request);
return JSON.parse(cached);
}
return null;
}
function push(key, value) {
redisClient.lpush(key, JSON.stringify(value));
}
async function pop(key) {
const cached = await redisClient.lpop(key);
debug("Pop cached", cached);
if (cached) {
debug("Got cached response", request);
return JSON.parse(cached);
}
return null;
@ -17,5 +31,7 @@ async function getCached(request) {
module.exports = {
cacheResponse,
getCached
getCached,
push,
pop
}

View File

@ -7,14 +7,23 @@ function getIndex(req, res) {
});
}
function getQuizz(req, res) {
res.render('quizz', {
title: 'SoundBirder Quizz',
csrf_token: req.csrfToken(),
locale: req.i18n.locale
})
}
function getAbout(req, res) {
res.render('about', {
title: 'About SoundBirder',
title: req.i18n.__('About SoundBirder'),
locale: req.i18n.locale
});
}
module.exports = {
getIndex,
getQuizz,
getAbout
}

View File

@ -5,14 +5,40 @@ const cache = require('./cache');
const eBird = require('@unclesamulus/ebird-api')(process.env.EBIRD_API_KEY);
const XenoCanto = require('@unclesamulus/xeno-canto-api');
const { choices, choice } = require('../utils/choices');
const { getRegion } = require('./region');
async function generateQuizz(coordinates, locale, size) {
function questionKey(region, locale, size) {
return `${region}:${locale}#${size}`;
}
async function getQuestionCached(region, locale, size) {
let key = questionKey(region, locale, size);
let quizz = await cache.pop(key);
if (!quizz) {
quizz = await generateQuestion(region, locale, size);
cache.push(key, quizz);
}
return quizz;
}
async function cacheQuestion(region, locale, size) {
let quizz = await generateQuestion(region, locale, size);
let key = questionKey(region, locale, size);
cache.push(key, quizz);
return quizz;
}
async function generateQuestion(region, locale, nb_propositions) {
const quizz = {}
try {
const speciesSelection = await getSpeciesSelection(coordinates, size);
let speciesSelection = [];
do {
speciesSelection = await getSpeciesSelection(region, nb_propositions);
} while (speciesSelection == []);
debugResponses(`Species selection: ${speciesSelection}`);
const speciesSelectionLocalized = await getLocalizedNames(speciesSelection, locale);
if (!speciesSelectionLocalized) {
throw 'Not localized';
}
debugResponses('Localized species selection:', speciesSelectionLocalized);
quizz.species = speciesSelectionLocalized;
debug("Got species selection", quizz.species);
@ -35,66 +61,89 @@ async function generateQuizz(coordinates, locale, size) {
return quizz;
}
async function getSpeciesSelection(coordinates, number) {
const { lat, lng } = coordinates;
const region = await getRegion(lat, lng);
// const regionCode = await getRegionCode(region);
const regionCode = region.country_code;
async function generateQuizz(region, locale, nb_questions) {
let questions = [];
let answers = [];
let start = Date.now();
let species_in_region_localized = await getSpeciesNamesInRegion(region, locale);
for (let i=0; i < nb_questions; i++) {
try {
let question_species = choice(species_in_region_localized);
let question_audio = await getAudio(question_species.sciName);
questions.push(question_audio);
answers.push(question_species);
} catch (error) {
console.error(error);
}
}
const millis = Date.now() - start;
console.log(`seconds elapsed = ${Math.floor(millis / 1000)}`);
return {questions: questions, answers: answers, propositions: species_in_region_localized};
}
async function getSpeciesNamesInRegion(region, locale) {
const cacheKey = `region-sppnames-${locale}-${region}`;
let cached = await cache.getCached(cacheKey);
if (cached && cached != []) {
return cached;
} else {
const species_in_region = await getSpeciesList(region);
const species_in_region_localized = await getLocalizedNames(species_in_region, locale);
cache.cacheResponse(cacheKey, species_in_region_localized);
return species_in_region_localized;
}
}
async function getSpeciesSelection(region, number) {
const regionCode = region;
const speciesList = await getSpeciesList(regionCode);
const speciesSelection = choices(speciesList, number);
debug("Species proposals:", speciesSelection)
return speciesSelection;
}
async function getLocalizedNames(speciesCodes, locale) {
let allNames = [];
for (let i = 0; i < speciesCodes.length; i++) {
const code = speciesCodes[i];
let flag = false;
try {
names = await cache.getCached(`${code}-${locale}`);
if (names) {
allNames.push(names);
flag = true;
}
} catch (error) {
console.error(error);
}
if (!flag) {
try {
const names = { speciesCode, sciName, comName } = await getLocalizedName(code, locale);
cache.cacheResponse(`${code}-${locale}`, names);
allNames.push(names);
} catch (error) {
console.error(error);
}
}
const localizedNames = [];
for (const speciesCode of speciesCodes) {
const localized = await getLocalizedName(speciesCode, locale);
if (!localized.sciName.includes(" x "))
localizedNames.push(localized);
}
return allNames;
return localizedNames;
}
function getLocalizedName(speciesCode, locale) {
return eBird.ref.taxonomy.ebird({
fmt: 'json',
locale: locale,
species: speciesCode
}).then(
response => {
const names = response[0];
debug("Got localized species names");
debugResponses(names);
return names;
}
).catch(error => {
throw error;
});
async function getLocalizedName(speciesCode, locale) {
const cacheKey = `sppnames-${locale}-${speciesCode}`;
const cached = await cache.getCached(cacheKey);
if (cached) {
return cached;
} else {
return eBird.ref.taxonomy.ebird({
fmt: 'json',
locale: locale,
species: speciesCode
}).then(
response => {
const names = response[0];
cache.cacheResponse(cacheKey, names);
debug("Got localized species names");
debugResponses(names);
return names;
}
).catch(error => {
throw error;
});
}
}
async function getSpeciesList(regionCode) {
const cached = await cache.getCached(`spplist-${regionCode}`);
if (cached) {
return cached;
if (cached != []) {
return cached;
} else {
console.log("Empty cached species list, retrieving again");
}
} else {
return eBird.product.spplist.in(regionCode)()
.then(species => {
@ -112,10 +161,16 @@ function getAudio(speciesScientificName) {
name: speciesScientificName,
quality: 'A'
}).then(response => {
debugResponses(response);
const { recordings } = response;
const randomRecord = choice(recordings);
const audio = randomRecord.file;
let randomRecord;
let audio;
for (let i=0; i < 3; i++) {
randomRecord = choice(recordings);
if ('file' in randomRecord) {
audio = randomRecord.file;
break;
}
}
return audio;
}).catch(error => {
throw error;
@ -123,5 +178,8 @@ function getAudio(speciesScientificName) {
}
module.exports = {
generateQuestion,
getQuestionCached,
cacheQuestion,
generateQuizz
}

View File

@ -1,46 +1,16 @@
require('dotenv').config();
const debug = require('debug')('soundbirder:api:region');
const cache = require('./cache');
const axios = require('axios');
require("dotenv").config();
const debug = require("debug")("soundbirder:api:region");
const cache = require("./cache");
const axios = require("axios");
const OPENCAGE_API_KEY = process.env.OPENCAGE_API_KEY;
const OPENCAGE_API_URL = 'https://api.opencagedata.com/geocode/v1/json?q=<lat>+<lon>&key=<key>';
const { lookUp } = require("geojson-places");
async function getRegion(lat, lon) {
const url = OPENCAGE_API_URL
.replace('<lat>', lat)
.replace('<lon>', lon)
.replace('<key>', OPENCAGE_API_KEY);
try {
const cached = await cache.getCached(`region-${lat}-${lon}`);
if (cached) {
return cached;
}
} catch (error) {
throw error;
}
return axios.get(url).then(response => {
const { results } = response.data;
const region = results[0].components;
region.country_code = region.country_code.toUpperCase();
cache.cacheResponse(url, region);
return { country_code, country, state, county } = region;
}).catch(error => {
throw error;
});
}
function getRegionCode({ country_code, country, state, county }) {
return eBird.ref.region.list('subnational1', country_code)()
.then(states => {
console.log(states);
const regionCode = states.find(region => region.name === state).code;
return regionCode;
}).catch(error => {
throw error;
});
const result = lookUp(lat, lon);
const region = result.country_a2;
return region;
}
module.exports = {
getRegion,
}
getRegion,
};

28
controllers/wikidata.js Normal file
View File

@ -0,0 +1,28 @@
// Query wikidata with axios
const axios = require("axios");
function query(sparqlQuery) {
const endpoint = 'https://query.wikidata.org/sparql';
const fullUrl = endpoint + '?query=' + encodeURIComponent( sparqlQuery );
const headers = { 'Accept': 'application/sparql-results+json' };
return axios.get(fullUrl, { headers }).then( body => body.data );
}
function encodeSparqlQuery(species) {
let sparqlTemplate = `#defaultView:ImageGrid
SELECT ?item ?itemLabel ?pic
WHERE
{
?item wdt:P225 "${species}" .
?item wdt:P18 ?pic
SERVICE wikibase:label { bd:serviceParam wikibase:language "[AUTO_LANGUAGE],en" }
}`
return sparqlTemplate;
}
function getPictures(species) {
return query(encodeSparqlQuery(species));
}
getPictures("Picus viridis").then((data) => console.log(data.results.bindings));

View File

@ -1,4 +1,5 @@
{
"Game": "Spiel",
"About": "About"
"Game": "Spiel",
"About": "About",
"It was a": "__It was a"
}

31
locales/en.json Executable file → Normal file
View File

@ -1,15 +1,26 @@
{
"Home": "Home",
"About": "About",
"Game": "Game",
"Contact": "Contact",
"SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird": "SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird",
"Author": "Author",
"The project is made with &hearts; by Samuel ORTION": "The project is made with &hearts; by Samuel ORTION",
"SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird.": "SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird.",
"The project is made with ♥ by Samuel ORTION.": "The project is made with ♥ by Samuel ORTION.",
"Welcome to SoundBirder' API": "Welcome to SoundBirder' API",
"About": "About",
"It was a": "It was a",
"Language": "Language",
"Submit": "Submit",
"Geolocalize yourself on the map": "Geolocalize yourself on the map",
"Start the game": "Start the game",
"Start a new quizz": "Start a new quizz",
"Return to the map": "Return to the map",
"Wrong!": "Wrong!",
"About SoundBirder": "About SoundBirder",
"SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird.": "SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird.",
"Author": "Author",
"The project is made with ♥ by Samuel Ortion.": "The project is made with ♥ by Samuel Ortion.",
"Correct!": "Correct!",
"It was a": "It was a"
"Set": "Set",
"Play the quizz": "Play the quizz",
"About this game": "About this game",
"Launch a quizz": "Launch a quizz",
"The project is made with ♥ by Samuel Ortion.": "The project is made with ♥ by Samuel Ortion.",
"Run a question": "Run a question",
"Play a quizz": "Play a quizz",
"Quizz": "Quizz",
"Enter species name": "Enter species name"
}

View File

@ -1,4 +1,9 @@
{
"Home": "Inicio",
"About": "Acerca de"
"Home": "Inicio",
"About": "Acerca de",
"SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird.": "__SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird.",
"Author": "__Author",
"The project is made with ♥ by Samuel Ortion.": "__The project is made with ♥ by Samuel Ortion.",
"It was a": "__It was a",
"Game": "__Game"
}

26
locales/fr.json Executable file → Normal file
View File

@ -2,10 +2,26 @@
"Game": "Jeu",
"About": "À propos",
"Contact": "Contact",
"SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird": "SoundBirder est une application web open-source qui permet d'apprendre à reconnaitre les chants d'oiseaux. Elle se base sur des enregistrements audios de Xeno-Canto et sur les données de répartitions d'eBird",
"Author": "Auteur",
"The project is made with ♥ by Samuel ORTION": "Ce projet est fait avec ♥ par Samuel ORTION",
"Correct!": "Correct!",
"Wrong!": "Incorrect!",
"It was a": "C'était un"
"The project is made with ♥ by Samuel Ortion.": "Ce projet est fait avec ♥ par Samuel Ortion.",
"Correct!": "Correct !",
"Wrong!": "Incorrect !",
"It was a": "C'était un",
"SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird.": "SoundBirder est une application open-source pour apprendre les chant d'oiseaux. Elle est basée sur les enregistrements de Xeno-Canto et sur les données d'eBird",
"Language": "Langue",
"Submit": "Envoyer",
"Geolocalize yourself on the map": "Geolocalisez vous sur la carte",
"Start the game": "Commencer le jeu",
"Start a new quizz": "Commencer un nouveau quiz",
"Return to the map": "Retour sur la carte",
"About SoundBirder": "À propos de SoundBirder",
"Set": "Définir",
"Play the quizz": "Jouer une partie",
"About this game": "À propos de ce jeu",
"Set language": "Modifier la langue",
"Launch a quizz": "Launch a quizz",
"Enter species name": "Enter species name",
"Run a question": "Run a question",
"Play a quizz": "Play a quizz",
"Quizz": "Quizz"
}

950
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "soundbirder",
"version": "0.0.0",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "node ./bin/www",
@ -11,6 +11,7 @@
"@unclesamulus/xeno-canto-api": "^0.0.0",
"autoprefixer": "^10.4.8",
"axios": "^0.27.2",
"codegrid-js": "github:hlaw/codegrid-js",
"connect-redis": "^6.1.3",
"cookie-parser": "~1.4.4",
"csurf": "^1.11.0",
@ -19,13 +20,18 @@
"express": "^4.18.2",
"express-session": "^1.17.3",
"feather-icons": "^4.29.0",
"geojson-places": "^1.0.8",
"http-errors": "~1.6.3",
"i18n-2": "^0.7.3",
"leaflet": "^1.8.0",
"local-reverse-geocoder": "^0.16.7",
"lodash": "^4.17.21",
"morgan": "~1.9.1",
"postcss": "^8.4.33",
"postcss-cli": "^11.0.0",
"pug": "^3.0.2",
"redis": "^4.3.0",
"tailwindcss": "^3.1.8"
"redis": "^4.6.13",
"tailwindcss": "^3.1.8",
"util": "^0.12.5"
}
}

View File

@ -1,5 +1,4 @@
const API_VERSION = "0";
const API_URL = `/api/${API_VERSION}`;
const API_URL = '/api';
const TOKEN = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
@ -20,27 +19,35 @@ async function query(endpoint, params) {
return await get(`${API_URL}/${endpoint}`, params);
}
function getQuizz(lat, lng) {
return query('game/quizz', { lat, lng })
.then(data => {
return data;
}).catch(error => {
throw error;
});
function getQuestion(region) {
return query('game/question', { region })
}
function checkAnswer(speciesId) {
return query(`game/check`, {
species: speciesId
}).then(data => {
return data;
}).catch(error => {
throw error;
});
function getQuizz(region) {
return query('game/quizz', { region })
}
function getRegion(lat, lon) {
return query('region', { lat, lon })
}
function checkAnswer(species) {
return query('game/check', {
species: species
})
}
// function getSpeciesCompletion(term) {
// return query('game/species/completion', {
// q: term
// })
// }
const client = {
getQuestion,
getQuizz,
getRegion,
checkAnswer
}

View File

@ -1,55 +1,94 @@
import { geolocationHandler, getCoordinates } from './map.js';
import client from './api-client.js';
const API_VERSION = "0";
let map = document.getElementById('map');
let startButton = document.querySelector('.game .start-button');
let gameMapStep = document.querySelector('.game-map-step');
let gameQuizzStep = document.querySelector('.game-quizz-step');
let gameQuizz = document.querySelector('.game-quizz');
let gameResultStep = document.querySelector('.game-results-step');
let gameLoading = document.querySelector('.game-loading');
let audio = document.querySelector('.game-audio audio');
let proposals = document.querySelector('.game-quizz-step .proposals');
let result = document.querySelector('.game-results-step .message');
let restartButton = document.querySelector('.game-quizz .restart-button');
let mapButton = document.querySelector('.game-quizz .map-button');
let resultComName = document.querySelector('.game-results-step .species .com');
let resultSciName = document.querySelector('.game-results-step .species .sci');
let region = 'FR';
function returnToMap() {
gameMapStep.classList.remove("none");
gameQuizz.classList.add("none");
gameResultStep.classList.add("none");
}
function geolocationStep() {
if (document.getElementById('map') != undefined)
if (map != undefined)
geolocationHandler();
let start_button = document.querySelector('.game .start-button');
if (start_button != undefined)
start_button.addEventListener('click', quizzStep);
if (startButton != undefined)
startButton.addEventListener('click', regionCoder);
}
function regionCoder() {
// Start by disallowing geolocation step
gameMapStep.classList.add('none');
// Retrieve coordinates from former done geolocation (TODO: fix the need of cookie)
const coordinates = getCoordinates();
let [lat, lon] = coordinates;
client.getRegion(lat, lon).then(data => {
region = data.region;
quizzStep();
});
}
function quizzStep() {
// Start by disallowing geolocation step
document.querySelector('.game-map-step').classList.toggle('none');
// Then allow the quizz step
document.querySelector('.game-quizz-step').classList.remove('none');
// Retrieve coordinates from former done geolocation (TODO: fix the need of cookie)
const coordinates = getCoordinates();
const [lat, lng] = coordinates;
console.log("Coordinates: " + `${lat}, ${lng}`);
client.getQuizz(lat, lng)
gameQuizz.classList.remove("none");
gameLoading.classList.remove("none");
audio.classList.add("none");
client.getQuestion(region)
.then(quizz => {
// Display the quizz
gameLoading.classList.add("none");
displayQuizz(quizz);
}).catch(error => {
console.log(error);
});
}
function displayQuizz(quizz) {
let audio = document.querySelector('.game-quizz-step audio');
if (quizz.audio == undefined) {
quizzStep();
return;
}
audio.src = quizz.audio;
audio.play();
let proposals = document.querySelector('.game-quizz-step .proposals');
audio.classList.remove("none"); // Display the audio controls
gameQuizzStep.classList.remove("none");
// audio.play();
proposals.innerHTML = "";
quizz.species.forEach(sp => {
if (sp.speciesCode == undefined) {
quizzStep();
return
}
let proposal = document.createElement('li');
proposal.classList.add('proposal');
let button = document.createElement('button');
button.classList.add('proposal-button');
button.classList.add('button');
button.value = sp.speciesCode;
button.innerText = sp.comName;
button.innerHTML = `${sp.comName} (<i>${sp.sciName}</i>)`;
proposal.appendChild(button);
proposals.appendChild(proposal);
button.addEventListener('click', verifyAnswer);
});
}
function verifyAnswer(event) {
let answer = event.target.value;
console.log(answer);
audio.pause();
client.checkAnswer(answer)
.then(data => {
let message_class;
@ -62,21 +101,23 @@ function verifyAnswer(event) {
console.log(error);
});
}
restartButton.addEventListener('click', restart);
mapButton.addEventListener('click', returnToMap);
function displayResult(message_class, message, species) {
let result = document.querySelector('.game-results-step .message');
document.querySelector('.game-quizz-step').classList.toggle('none');
gameQuizzStep.classList.toggle('none');
result.classList.remove('success', 'error');
result.classList.add(message_class);
result.innerText = message;
document.querySelector('.game-results-step').classList.remove('none');
document.querySelector('.game-results-step .restart-button').addEventListener('click', restart);
document.querySelector('.game-results-step .species .com').innerText = species.comName;
document.querySelector('.game-results-step .species .sci').innerText = species.sciName;
gameResultStep.classList.remove('none');
resultComName.innerText = species.comName;
resultSciName.innerText = species.sciName;
}
function restart() {
window.location.reload();
gameResultStep.classList.add("none");
gameMapStep.classList.add("none");
quizzStep();
}
function game() {

33
public/javascripts/lang.js Executable file
View File

@ -0,0 +1,33 @@
let languageSelectorButton = document.getElementById('language-selector-button');
let languageSelector = document.getElementById('language-selector');
let selecting = false;
function addReplaceLangCode(url, langCode) {
let a = window.location;
let path = a.pathname.split('/');
path.shift();
if(path[0].length == 2) {
path[0] = langCode;
}else{
path.unshift(langCode);
}
return a.protocol + '//' +
a.host + '/' + path.join('/') +
(a.search != '' ? a.search : '') +
(a.hash != '' ? a.hash : '');
}
function update() {
let langCode = languageSelector.value;
let url = addReplaceLangCode(window.location.href, langCode);
window.location = url;
}
languageSelector.addEventListener('click', function() {
if (selecting) {
update();
selecting = false;
} else {
selecting = true;
}
});

View File

@ -6,7 +6,6 @@ function getCoordinates() {
let lng = getCookie("lng");
if (lat != undefined && lng != undefined) {
location = [lat, lng];
console.log(`Got a previous geolocation cookie at ${location[0]}, ${location[1]}`)
}
return location;
}
@ -21,7 +20,7 @@ function geolocationHandler() {
let location = getCoordinates();
let message = document.querySelector('.message');
// Init map
let map = L.map('map').setView(location, 15);
let map = L.map('map').setView(location, 5);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);

View File

@ -0,0 +1,32 @@
import client from './api-client.js';
let Quizz = {
questions: [],
answers: [],
propositions: [],
region: 'FR', // TODO: update
questionPointer: 0,
init: function() {
let { questions, answers, propositions } = client.getQuizz(region);
this.questions = questions;
this.answers = answers;
this.propositions = propositions;
this.questionPointer = 0;
},
nextQuestion: function() {
this.questionPointer += 1;
this.render();
},
previousQuestion: function() {
if (this.questionPointer >= 1) {
this.questionPointer -= 1;
}
this.render()
},
render: function() {
}
}

View File

@ -1,4 +1,8 @@
header h1 {
margin-bottom: 1em;
}
.link {
position: relative;
display: inline-block;
@ -29,10 +33,12 @@
footer .link:after {
border-bottom: 2px dotted white;
}
#map {
height: 50vh;
min-height: 60vh;
}
.invit {
min-height: 60vh;
}
@ -50,4 +56,12 @@ footer .link:after {
.proposal-button {
margin: 1em 0;
}
.game-loading {
margin: 0;
position: absolute;
top: 50%;
left: calc(50% - 4em);
transform: translateY(-50%);
}

101
public/stylesheets/spinner.css Executable file
View File

@ -0,0 +1,101 @@
/* html, body {
height: 100%;
}
*/
/* body {
display: flex;
align-items: center;
justify-content: center;
} */
.spinner {
-webkit-animation: rotator 1.4s linear infinite;
animation: rotator 1.4s linear infinite;
}
@-webkit-keyframes rotator {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(270deg);
}
}
@keyframes rotator {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(270deg);
}
}
.path {
stroke-dasharray: 187;
stroke-dashoffset: 0;
transform-origin: center;
-webkit-animation: dash 1.4s ease-in-out infinite, colors 5.6s ease-in-out infinite;
animation: dash 1.4s ease-in-out infinite, colors 5.6s ease-in-out infinite;
}
@-webkit-keyframes colors {
0% {
stroke: #4285F4;
}
25% {
stroke: #DE3E35;
}
50% {
stroke: #F7C223;
}
75% {
stroke: #1B9A59;
}
100% {
stroke: #4285F4;
}
}
@keyframes colors {
0% {
stroke: #4285F4;
}
25% {
stroke: #DE3E35;
}
50% {
stroke: #F7C223;
}
75% {
stroke: #1B9A59;
}
100% {
stroke: #4285F4;
}
}
@-webkit-keyframes dash {
0% {
stroke-dashoffset: 187;
}
50% {
stroke-dashoffset: 46.75;
transform: rotate(135deg);
}
100% {
stroke-dashoffset: 187;
transform: rotate(450deg);
}
}
@keyframes dash {
0% {
stroke-dashoffset: 187;
}
50% {
stroke-dashoffset: 46.75;
transform: rotate(135deg);
}
100% {
stroke-dashoffset: 187;
transform: rotate(450deg);
}
}

View File

@ -1,5 +1,5 @@
/*
! tailwindcss v3.1.8 | MIT License | https://tailwindcss.com
! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com
*//*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
@ -24,6 +24,8 @@
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
*/
html {
@ -33,6 +35,8 @@ html {
-o-tab-size: 4;
tab-size: 4; /* 3 */
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* 4 */
font-feature-settings: normal; /* 5 */
font-variation-settings: normal; /* 6 */
}
/*
@ -163,6 +167,8 @@ optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-feature-settings: inherit; /* 1 */
font-variation-settings: inherit; /* 1 */
font-size: 100%; /* 1 */
font-weight: inherit; /* 1 */
line-height: inherit; /* 1 */
@ -300,6 +306,13 @@ menu {
padding: 0;
}
/*
Reset default styling for dialogs.
*/
dialog {
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
@ -368,6 +381,11 @@ video {
height: auto;
}
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden] {
display: none;
}
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
@ -382,53 +400,9 @@ video {
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
::-webkit-backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
@ -476,6 +450,9 @@ video {
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
@ -515,6 +492,7 @@ h1 {
color: var(--tw-main-color);
}
.button {
margin: 0.5rem;
border-radius: 0.25rem;
--tw-bg-opacity: 1;
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
@ -530,41 +508,420 @@ h1 {
--tw-bg-opacity: 1;
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
}
.nav-item {
display: flex;
align-self: center;
padding: 0.5rem;
}
.menu {
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.menu {
flex-direction: row;
}
}
.pointer-events-none {
pointer-events: none;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.-top-1 {
top: -0.25rem;
}
.-top-1\.5 {
top: -0.375rem;
}
.left-0 {
left: 0px;
}
.block {
display: block;
}
.flex {
display: flex;
}
.h-10 {
height: 2.5rem;
}
.h-full {
height: 100%;
}
.min-h-screen {
min-height: 100vh;
}
.w-10 {
width: 2.5rem;
}
.w-full {
width: 100%;
}
.min-w-\[100px\] {
min-width: 100px;
}
.flex-1 {
flex: 1 1 0%;
}
.select-none {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.flex-row {
flex-direction: row;
}
.flex-col {
flex-direction: column;
}
.justify-evenly {
justify-content: space-evenly;
.justify-between {
justify-content: space-between;
}
.rounded-\[7px\] {
border-radius: 7px;
}
.border {
border-width: 1px;
}
.border-t-transparent {
border-top-color: transparent;
}
.bg-black {
--tw-bg-opacity: 1;
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
}
.bg-transparent {
background-color: transparent;
}
.p-1 {
padding: 0.25rem;
}
.p-5 {
padding: 1.25rem;
}
.px-3 {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.py-2\.5 {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
.text-center {
text-align: center;
}
.font-sans {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
.text-\[11px\] {
font-size: 11px;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.font-normal {
font-weight: 400;
}
.leading-tight {
line-height: 1.25;
}
.text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.outline {
outline-style: solid;
}
.outline-0 {
outline-width: 0px;
}
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
nav {
background-color: white;
opacity: 50%;
}
.before\:pointer-events-none::before {
content: var(--tw-content);
pointer-events: none;
}
.before\:mr-1::before {
content: var(--tw-content);
margin-right: 0.25rem;
}
.before\:mt-\[6\.5px\]::before {
content: var(--tw-content);
margin-top: 6.5px;
}
.before\:box-border::before {
content: var(--tw-content);
box-sizing: border-box;
}
.before\:block::before {
content: var(--tw-content);
display: block;
}
.before\:h-1::before {
content: var(--tw-content);
height: 0.25rem;
}
.before\:h-1\.5::before {
content: var(--tw-content);
height: 0.375rem;
}
.before\:w-2::before {
content: var(--tw-content);
width: 0.5rem;
}
.before\:w-2\.5::before {
content: var(--tw-content);
width: 0.625rem;
}
.before\:rounded-tl-md::before {
content: var(--tw-content);
border-top-left-radius: 0.375rem;
}
.before\:border-l::before {
content: var(--tw-content);
border-left-width: 1px;
}
.before\:border-t::before {
content: var(--tw-content);
border-top-width: 1px;
}
.before\:transition-all::before {
content: var(--tw-content);
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.after\:pointer-events-none::after {
content: var(--tw-content);
pointer-events: none;
}
.after\:ml-1::after {
content: var(--tw-content);
margin-left: 0.25rem;
}
.after\:mt-\[6\.5px\]::after {
content: var(--tw-content);
margin-top: 6.5px;
}
.after\:box-border::after {
content: var(--tw-content);
box-sizing: border-box;
}
.after\:block::after {
content: var(--tw-content);
display: block;
}
.after\:h-1::after {
content: var(--tw-content);
height: 0.25rem;
}
.after\:h-1\.5::after {
content: var(--tw-content);
height: 0.375rem;
}
.after\:w-2::after {
content: var(--tw-content);
width: 0.5rem;
}
.after\:w-2\.5::after {
content: var(--tw-content);
width: 0.625rem;
}
.after\:flex-grow::after {
content: var(--tw-content);
flex-grow: 1;
}
.after\:rounded-tr-md::after {
content: var(--tw-content);
border-top-right-radius: 0.375rem;
}
.after\:border-r::after {
content: var(--tw-content);
border-right-width: 1px;
}
.after\:border-t::after {
content: var(--tw-content);
border-top-width: 1px;
}
.after\:transition-all::after {
content: var(--tw-content);
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.placeholder-shown\:border:-moz-placeholder-shown {
border-width: 1px;
}
.placeholder-shown\:border:placeholder-shown {
border-width: 1px;
}
.empty\:\!bg-gray-900:empty {
--tw-bg-opacity: 1 !important;
background-color: rgb(17 24 39 / var(--tw-bg-opacity)) !important;
}
.focus\:border-2:focus {
border-width: 2px;
}
.focus\:border-gray-900:focus {
--tw-border-opacity: 1;
border-color: rgb(17 24 39 / var(--tw-border-opacity));
}
.focus\:border-t-transparent:focus {
border-top-color: transparent;
}
.focus\:outline-0:focus {
outline-width: 0px;
}
.disabled\:border-0:disabled {
border-width: 0px;
}
.peer:-moz-placeholder-shown ~ .peer-placeholder-shown\:text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.peer:placeholder-shown ~ .peer-placeholder-shown\:text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.peer:-moz-placeholder-shown ~ .peer-placeholder-shown\:leading-\[3\.75\] {
line-height: 3.75;
}
.peer:placeholder-shown ~ .peer-placeholder-shown\:leading-\[3\.75\] {
line-height: 3.75;
}
.peer:-moz-placeholder-shown ~ .peer-placeholder-shown\:before\:border-transparent::before {
content: var(--tw-content);
border-color: transparent;
}
.peer:placeholder-shown ~ .peer-placeholder-shown\:before\:border-transparent::before {
content: var(--tw-content);
border-color: transparent;
}
.peer:-moz-placeholder-shown ~ .peer-placeholder-shown\:after\:border-transparent::after {
content: var(--tw-content);
border-color: transparent;
}
.peer:placeholder-shown ~ .peer-placeholder-shown\:after\:border-transparent::after {
content: var(--tw-content);
border-color: transparent;
}
.peer:focus ~ .peer-focus\:text-\[11px\] {
font-size: 11px;
}
.peer:focus ~ .peer-focus\:leading-tight {
line-height: 1.25;
}
.peer:focus ~ .peer-focus\:text-gray-900 {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity));
}
.peer:focus ~ .peer-focus\:before\:border-l-2::before {
content: var(--tw-content);
border-left-width: 2px;
}
.peer:focus ~ .peer-focus\:before\:border-t-2::before {
content: var(--tw-content);
border-top-width: 2px;
}
.peer:focus ~ .peer-focus\:before\:border-gray-900::before {
content: var(--tw-content);
--tw-border-opacity: 1;
border-color: rgb(17 24 39 / var(--tw-border-opacity));
}
.peer:focus ~ .peer-focus\:after\:border-r-2::after {
content: var(--tw-content);
border-right-width: 2px;
}
.peer:focus ~ .peer-focus\:after\:border-t-2::after {
content: var(--tw-content);
border-top-width: 2px;
}
.peer:focus ~ .peer-focus\:after\:border-gray-900::after {
content: var(--tw-content);
--tw-border-opacity: 1;
border-color: rgb(17 24 39 / var(--tw-border-opacity));
}
.peer:disabled ~ .peer-disabled\:text-transparent {
color: transparent;
}
.peer:disabled ~ .peer-disabled\:before\:border-transparent::before {
content: var(--tw-content);
border-color: transparent;
}
.peer:disabled ~ .peer-disabled\:after\:border-transparent::after {
content: var(--tw-content);
border-color: transparent;
}

View File

@ -13,6 +13,19 @@
}
.button {
@apply text-white font-bold py-2 px-4 rounded bg-green-500 hover:bg-green-700;
@apply text-white font-bold py-2 px-4 m-2 rounded bg-green-500 hover:bg-green-700;
}
.nav-item {
@apply flex flex self-center p-2;
}
.menu {
@apply flex flex-col md:flex-row;
}
}
nav {
background-color: white;
opacity: 50%;
}

View File

@ -1,15 +1,19 @@
const redis = require('redis');
const util = require('util');
const redisHost = process.env.REDIS_HOST ? process.env.REDIS_HOST : 'localhost';
const redisPort = process.env.REDIS_PORT ? process.env.REDIS_PORT : 6379;
const url = `redis://${redisHost}:${redisPort}`;
const redisClient = redis.createClient({
url,
legacyMode: true
});
redisClient.pget = util.promisify(redisClient.get);
(async () => {
redisClient.connect()
redisClient.connect().catch(console.error)
})();
module.exports = {

View File

@ -2,8 +2,9 @@ const express = require('express');
const router = express.Router();
const apiController = require('../controllers/api.js');
router.route('/')
.get(apiController.getHome);
router.route('/game/question')
.get(apiController.game.question);
router.route('/game/quizz')
.get(apiController.game.quizz);
@ -11,4 +12,7 @@ router.route('/game/quizz')
router.route('/game/check')
.get(apiController.game.check);
router.route('/region')
.get(apiController.region);
module.exports = router;

View File

@ -5,6 +5,9 @@ const indexController = require('../controllers/index');
router.route('/')
.get(indexController.getIndex);
router.route('/quizz')
.get(indexController.getQuizz);
router.route('/about')
.get(indexController.getAbout);

71
utils/extract-i18n.js Executable file
View File

@ -0,0 +1,71 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const messagePattern = /\_\_\('([^']+)'\)/g;
function getSourceFiles(dirs) {
let sourceFiles = [];
for (let dir of dirs) {
let files = fs.readdirSync(dir);
let sourceFilesDir = files.filter(file => file.endsWith('.pug') || file.endsWith('.js'));
sourceFilesDir = sourceFilesDir.map(file => path.join(dir, file));
sourceFiles = [...sourceFiles, ...sourceFilesDir];
}
return sourceFiles;
}
function extractAllMessages(files) {
let allMessages = [];
files.forEach(file => {
const content = fs.readFileSync(file, 'utf8');
messages = extractMessages(content);
allMessages = allMessages.concat(messages);
});
return allMessages;
}
function extractMessages(content) {
let messages = [];
let match;
while (match = messagePattern.exec(content)) {
messages.push(match[1]);
}
return messages;
}
function dumps(dir, locales, messages) {
const uniqueMessages = [...new Set(messages)];
locales.forEach(lang => {
const file = path.join(dir, 'locales', lang + '.json');
let content = JSON.parse(fs.readFileSync(file, 'utf8'));
uniqueMessages.forEach(message => {
if (!content[message]) {
content[message] = `__${message}`;
}
});
fs.writeFileSync(file, JSON.stringify(content, null, 2));
});
}
const extractor = {
run(dirs) {
const files = getSourceFiles(dirs);
const messages = extractAllMessages(files);
return messages;
},
dumps
};
const [,, ...args] = process.argv
const DIRS = ["controllers", "views", "views/auth"];
const LOCALES = args.slice(args.length) || ['en'];
const keys = extractor.run(DIRS);
extractor.dumps('.', LOCALES, keys);

2
utils/translate.sh Executable file
View File

@ -0,0 +1,2 @@
#!/usr/bin/env bash
node ./utils/extract-i18n.js views fr de es

View File

@ -1,8 +1,7 @@
extends layout.pug
block content
p #{ __("SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird.") }
h2 #{ __("Author") }
p #{ __("The project is made with ♥ by Samuel ORTION.") }
p #{ __('SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird.') }
h2 #{ __('Author') }
p #{ __('The project is made with ♥ by Samuel Ortion.') }

View File

@ -1,5 +0,0 @@
extends layout
block content
h2= __('Welcome to SoundBirder\'s API')
.version api v#{version}

View File

@ -1,22 +1,38 @@
.game
.game-map-step
#map.h-100
button.button.geolocation-button
i(data-feather="map-pin")
button.button.start-button
i(data-feather="play")
.game-quizz-step.none
ul.proposals
audio
.game-results-step.none
p.message
.species.answer
p #{ __('It was a') }
span.com
span.sci
button.button.restart-button
i(data-feather="repeat")
.invit
#map
.controls
button.button.geolocation-button(aria-label=__('Geolocalize yourself on the map'))
i(data-feather="map-pin")
button.button.start-button(aria-label=__('Start the game'))
i(data-feather="play")
button.button.quizz-start-button(aria-label=__('Launch a quizz'))
i(data-feather="clipboard")
.game-quizz.none
.invit
.game-loading
<svg class="spinner" width="65px" height="65px" viewBox="0 0 66 66" xmlns="http://www.w3.org/2000/svg">
<circle class="path" fill="none" stroke-width="6" stroke-linecap="round" cx="33" cy="33" r="30"></circle>
</svg>
.game-audio
.game-quizz-step.none
ul.proposals
.game-results-step.none
p.message
.species.answer
p #{ __('It was a') }
span.com
span.sci
audio(controls).none
.controls
button.button.restart-button(aria-label=__('Start a new quizz'))
i(data-feather="repeat")
button.button.map-button(aria-label=__('Return to the map'))
i(data-feather="map")
link(rel="stylesheet" href="/dist/leaflet/leaflet.css")
link(rel="stylesheet" href="/stylesheets/spinner.css")
script(src="/dist/leaflet/leaflet.js")
script(src="/dist/axios/axios.min.js")
script(src="/javascripts/game.js" type="module")

11
views/lang.pug Executable file
View File

@ -0,0 +1,11 @@
- var languages = {en: "English", fr: "Français"} // , de: "Deutsch", es: "Español"}
.flex.flex-row
.relative.h-10.w-10(class='min-w-[100px]')
select#language-selector.peer.h-full.w-full.border.border-blue-gray-200.border-t-transparent.bg-transparent.px-3.font-sans.text-sm.font-normal.text-blue-gray-700.outline.outline-0.transition-all(class='rounded-[7px] py-2.5 placeholder-shown:border placeholder-shown:border-blue-gray-200 placeholder-shown:border-t-blue-gray-200 empty:!bg-gray-900 focus:border-2 focus:border-gray-900 focus:border-t-transparent focus:outline-0 disabled:border-0 disabled:bg-blue-gray-50')
each language, code in languages
- var selected = code == locale
option.pointer-events-none.absolute.left-0.flex.h-full.w-full.select-none.font-normal.leading-tight.text-blue-gray-400.transition-all(class="before:content[' '] after:content[' '] -top-1.5 text-[11px] before:pointer-events-none before:mt-[6.5px] before:mr-1 before:box-border before:block before:h-1.5 before:w-2.5 before:rounded-tl-md before:border-t before:border-l before:border-blue-gray-200 before:transition-all after:pointer-events-none after:mt-[6.5px] after:ml-1 after:box-border after:block after:h-1.5 after:w-2.5 after:flex-grow after:rounded-tr-md after:border-t after:border-r after:border-blue-gray-200 after:transition-all peer-placeholder-shown:text-sm peer-placeholder-shown:leading-[3.75] peer-placeholder-shown:text-blue-gray-500 peer-placeholder-shown:before:border-transparent peer-placeholder-shown:after:border-transparent peer-focus:text-[11px] peer-focus:leading-tight peer-focus:text-gray-900 peer-focus:before:border-t-2 peer-focus:before:border-l-2 peer-focus:before:border-gray-900 peer-focus:after:border-t-2 peer-focus:after:border-r-2 peer-focus:after:border-gray-900 peer-disabled:text-transparent peer-disabled:before:border-transparent peer-disabled:after:border-transparent peer-disabled:peer-placeholder-shown:text-blue-gray-500" value=code selected=selected) #{ language }
label.pointer-events-none.absolute.left-0.flex.h-full.w-full.select-none.font-normal.leading-tight.text-blue-gray-400.transition-all(class="before:content[' '] after:content[' '] -top-1.5 text-[11px] before:pointer-events-none before:mt-[6.5px] before:mr-1 before:box-border before:block before:h-1.5 before:w-2.5 before:rounded-tl-md before:border-t before:border-l before:border-blue-gray-200 before:transition-all after:pointer-events-none after:mt-[6.5px] after:ml-1 after:box-border after:block after:h-1.5 after:w-2.5 after:flex-grow after:rounded-tr-md after:border-t after:border-r after:border-blue-gray-200 after:transition-all peer-placeholder-shown:text-sm peer-placeholder-shown:leading-[3.75] peer-placeholder-shown:text-blue-gray-500 peer-placeholder-shown:before:border-transparent peer-placeholder-shown:after:border-transparent peer-focus:text-[11px] peer-focus:leading-tight peer-focus:text-gray-900 peer-focus:before:border-t-2 peer-focus:before:border-l-2 peer-focus:before:border-gray-900 peer-focus:after:border-t-2 peer-focus:after:border-r-2 peer-focus:after:border-gray-900 peer-disabled:text-transparent peer-disabled:before:border-transparent peer-disabled:after:border-transparent peer-disabled:peer-placeholder-shown:text-blue-gray-500")
| #{ __('Language') }
//- button#language-selector-button.p-1.btn.button.secondary-button #{ __('Set language') }

View File

@ -1,5 +1,5 @@
doctype html
html
html(lang=locale)
head
title= title
meta(name="viewport" content="width=device-width, initial-scale=1.0")
@ -10,21 +10,27 @@ html
body
.flex.flex-col.min-h-screen
.flex-1.p-5
nav
- var i18n_prefix = locale ? '/' + locale : ''
ul.flex.flex-row.text-center.justify-evenly
li
a(href=`${i18n_prefix}/`) #{ __("Game") }
li
a(href=`${i18n_prefix}/about`) #{ __("About") }
.menu.justify-between
nav(role="navigation").flex.flex-row
- var i18n_prefix = locale ? '/' + locale : ''
span.nav-item
a(href=`${i18n_prefix}/` title=__('Run a question')) #{ __('Game') }
span.nav-item
a(href=`${i18n_prefix}/quizz`, title=__('Play a quizz')) #{ __('Quizz') }
span.nav-item
a(href=`${i18n_prefix}/about` title=__('About this game')) #{ __('About') }
include lang.pug
header
h1= title
main
main.flex-1
block content
footer.w-full.bg-black.text-white.text-center.p-5
.description
.copyright Copyright &copy; 2022 -
.copyright
a(href="https://www.gnu.org/licenses/agpl-3.0.en.html") 🄯
| 2022 - by
span.author
a(href="https://samuel.ortion.fr" class="link") Samuel ORTION
a(href="https://samuel.ortion.fr" class="link") Samuel Ortion
script(src="/javascripts/app.js" type="module")
script(src="/dist/feather/feather.min.js")
script(src="/dist/feather/feather.min.js")
script(src="/javascripts/lang.js")

9
views/quizz.pug Normal file
View File

@ -0,0 +1,9 @@
extends layout
block content
.game
.quizz-question
audio(controls)
input(type="text", text="", placeholder=__('Enter species name')).p-1
script(src="/dist/axios/axios.min.js")
script(src="/javascripts/game.js" type="module")