Compare commits
16 Commits
Author | SHA1 | Date |
---|---|---|
Samuel Ortion | b0d267c6b3 | |
Samuel Ortion | 9d2822709e | |
Samuel Ortion | 150da0f844 | |
Samuel Ortion | e4a7479f19 | |
Samuel Ortion | 17aaee56ad | |
Samuel Ortion | c588768401 | |
Samuel Ortion | b9af8431dd | |
Samuel Ortion | 079556a061 | |
Samuel Ortion | db316e45e3 | |
Samuel Ortion | 85f2200380 | |
Samuel Ortion | f6bfa6bce5 | |
Samuel Ortion | 3e28cb35e9 | |
Samuel Ortion | 40079e1145 | |
Samuel Ortion | 94d30121d4 | |
Samuel Ortion | 07a42f4d8d | |
Samuel Ortion | fdb868414a |
|
@ -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
16
app.js
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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));
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"Game": "Spiel",
|
||||
"About": "About"
|
||||
"Game": "Spiel",
|
||||
"About": "About",
|
||||
"It was a": "__It was a"
|
||||
}
|
|
@ -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 ♥ by Samuel ORTION": "The project is made with ♥ 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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
|
@ -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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(map);
|
||||
|
|
|
@ -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() {
|
||||
|
||||
}
|
||||
}
|
|
@ -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%);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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%;
|
||||
}
|
6
redis.js
6
redis.js
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env bash
|
||||
node ./utils/extract-i18n.js views fr de es
|
|
@ -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.') }
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
extends layout
|
||||
|
||||
block content
|
||||
h2= __('Welcome to SoundBirder\'s API')
|
||||
.version api v#{version}
|
|
@ -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")
|
|
@ -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') }
|
|
@ -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 © 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")
|
|
@ -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")
|
Loading…
Reference in New Issue