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();
|
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 createError = require('http-errors');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
|
@ -34,11 +38,11 @@ const sess = {
|
||||||
cookie: { secure: false }
|
cookie: { secure: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app.get('env') === 'production') {
|
// if (app.get('env') === 'production') {
|
||||||
app.set('trust proxy', 1); // trust first proxy
|
// app.set('trust proxy', 1); // trust first proxy
|
||||||
sess.cookie.secure = true; // serve secure cookies
|
// sess.cookie.secure = true; // serve secure cookies
|
||||||
sess.store = new RedisStore({ client: redisClient });
|
// sess.store = new RedisStore({ host: redisHost, port: redisPort, client: redisClient });
|
||||||
}
|
// }
|
||||||
|
|
||||||
app.use(session(sess));
|
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/feather', express.static('node_modules/feather-icons/dist'));
|
||||||
app.use('/dist/axios', express.static('node_modules/axios/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 });
|
const csrfProtection = csrf({ cookie: true });
|
||||||
app.use(csrfProtection);
|
app.use(csrfProtection);
|
||||||
|
|
|
@ -3,32 +3,60 @@ const debug = require('debug')('soundbirder:api');
|
||||||
const debugLocale = require('debug')('soundbirder:locale');
|
const debugLocale = require('debug')('soundbirder:locale');
|
||||||
const debugResponses = require('debug')('soundbirder:api:responses');
|
const debugResponses = require('debug')('soundbirder:api:responses');
|
||||||
const quizzController = require('./quizz');
|
const quizzController = require('./quizz');
|
||||||
|
const { getRegion } = require('./region');
|
||||||
|
|
||||||
const QUIZZ_SIZE = process.env.QUIZZ_SIZE ? process.env.QUIZZ_SIZE : 5;
|
const QUIZZ_SIZE = process.env.QUIZZ_SIZE ? process.env.QUIZZ_SIZE : 5;
|
||||||
|
|
||||||
function getHome(req, res) {
|
async function region(req, res) {
|
||||||
res.render('api', {
|
let {lat, lon} = req.query;
|
||||||
title: "SoundBirder api",
|
lat = parseFloat(lat);
|
||||||
version: 0
|
lon = parseFloat(lon);
|
||||||
});
|
const region = await getRegion(lat, lon);
|
||||||
|
res.json({
|
||||||
|
region
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function quizz(req, res) {
|
async function question(req, res) {
|
||||||
debug('Generating quizz');
|
debug('Generating question');
|
||||||
const { lat, lng } = req.query;
|
const { region } = req.query;
|
||||||
debug(`Coordinates: ${lat}, ${lng}`);
|
req.session.region = region;
|
||||||
const locale = req.i18n.locale;
|
const locale = req.i18n.locale;
|
||||||
debugLocale("Locale:", locale);
|
debugLocale("Locale:", locale);
|
||||||
quizzController.generateQuizz({ lat, lng }, locale, QUIZZ_SIZE)
|
quizzController.getQuestionCached(region, locale, QUIZZ_SIZE)
|
||||||
.then(({ species, answer, audio }) => {
|
.then(({ species, answer, audio }) => {
|
||||||
req.session.answer = answer;
|
req.session.answer = answer;
|
||||||
res.json({ species, audio });
|
res.json({ species, audio }).send();
|
||||||
debug("Quizz sent");
|
debug("Quizz sent");
|
||||||
|
quizzController.cacheQuestion(); // Prepare the next question in advance.
|
||||||
|
debug("New quizz cached");
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
debug("Faced error while generating quizz");
|
debug("Faced error while generating quizz");
|
||||||
res.json({ error });
|
debug(error)
|
||||||
throw 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 = {
|
const game = {
|
||||||
check,
|
check,
|
||||||
quizz
|
question,
|
||||||
|
quizz,
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getHome,
|
game,
|
||||||
game
|
region
|
||||||
}
|
}
|
|
@ -8,7 +8,7 @@ function indexPage(req, res) {
|
||||||
|
|
||||||
function loginPage(req, res) {
|
function loginPage(req, res) {
|
||||||
res.render('auth/login', {
|
res.render('auth/login', {
|
||||||
title: req.i18n.__("SoundBirder - Login"),
|
title: req.i18n.__('SoundBirder - Login'),
|
||||||
locale: req.i18n.locale
|
locale: req.i18n.locale
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ function logout(req, res) {
|
||||||
|
|
||||||
function registerPage(req, res) {
|
function registerPage(req, res) {
|
||||||
res.render('auth/register', {
|
res.render('auth/register', {
|
||||||
title: req.i18n.__("SoundBirder - Register"),
|
title: req.i18n.__('SoundBirder - Register'),
|
||||||
locale: req.i18n.locale
|
locale: req.i18n.locale
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,10 +6,24 @@ function cacheResponse(request, response) {
|
||||||
redisClient.set(request, JSON.stringify(response));
|
redisClient.set(request, JSON.stringify(response));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function getCached(request) {
|
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) {
|
if (cached) {
|
||||||
debug("Got cached response", request);
|
|
||||||
return JSON.parse(cached);
|
return JSON.parse(cached);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
@ -17,5 +31,7 @@ async function getCached(request) {
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
cacheResponse,
|
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) {
|
function getAbout(req, res) {
|
||||||
res.render('about', {
|
res.render('about', {
|
||||||
title: 'About SoundBirder',
|
title: req.i18n.__('About SoundBirder'),
|
||||||
locale: req.i18n.locale
|
locale: req.i18n.locale
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getIndex,
|
getIndex,
|
||||||
|
getQuizz,
|
||||||
getAbout
|
getAbout
|
||||||
}
|
}
|
|
@ -5,14 +5,40 @@ const cache = require('./cache');
|
||||||
const eBird = require('@unclesamulus/ebird-api')(process.env.EBIRD_API_KEY);
|
const eBird = require('@unclesamulus/ebird-api')(process.env.EBIRD_API_KEY);
|
||||||
const XenoCanto = require('@unclesamulus/xeno-canto-api');
|
const XenoCanto = require('@unclesamulus/xeno-canto-api');
|
||||||
const { choices, choice } = require('../utils/choices');
|
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 = {}
|
const quizz = {}
|
||||||
try {
|
try {
|
||||||
const speciesSelection = await getSpeciesSelection(coordinates, size);
|
let speciesSelection = [];
|
||||||
|
do {
|
||||||
|
speciesSelection = await getSpeciesSelection(region, nb_propositions);
|
||||||
|
} while (speciesSelection == []);
|
||||||
debugResponses(`Species selection: ${speciesSelection}`);
|
debugResponses(`Species selection: ${speciesSelection}`);
|
||||||
const speciesSelectionLocalized = await getLocalizedNames(speciesSelection, locale);
|
const speciesSelectionLocalized = await getLocalizedNames(speciesSelection, locale);
|
||||||
|
if (!speciesSelectionLocalized) {
|
||||||
|
throw 'Not localized';
|
||||||
|
}
|
||||||
debugResponses('Localized species selection:', speciesSelectionLocalized);
|
debugResponses('Localized species selection:', speciesSelectionLocalized);
|
||||||
quizz.species = speciesSelectionLocalized;
|
quizz.species = speciesSelectionLocalized;
|
||||||
debug("Got species selection", quizz.species);
|
debug("Got species selection", quizz.species);
|
||||||
|
@ -35,66 +61,89 @@ async function generateQuizz(coordinates, locale, size) {
|
||||||
return quizz;
|
return quizz;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSpeciesSelection(coordinates, number) {
|
async function generateQuizz(region, locale, nb_questions) {
|
||||||
const { lat, lng } = coordinates;
|
let questions = [];
|
||||||
const region = await getRegion(lat, lng);
|
let answers = [];
|
||||||
// const regionCode = await getRegionCode(region);
|
let start = Date.now();
|
||||||
const regionCode = region.country_code;
|
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 speciesList = await getSpeciesList(regionCode);
|
||||||
const speciesSelection = choices(speciesList, number);
|
const speciesSelection = choices(speciesList, number);
|
||||||
debug("Species proposals:", speciesSelection)
|
|
||||||
return speciesSelection;
|
return speciesSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function getLocalizedNames(speciesCodes, locale) {
|
async function getLocalizedNames(speciesCodes, locale) {
|
||||||
let allNames = [];
|
const localizedNames = [];
|
||||||
for (let i = 0; i < speciesCodes.length; i++) {
|
for (const speciesCode of speciesCodes) {
|
||||||
const code = speciesCodes[i];
|
const localized = await getLocalizedName(speciesCode, locale);
|
||||||
let flag = false;
|
if (!localized.sciName.includes(" x "))
|
||||||
try {
|
localizedNames.push(localized);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return allNames;
|
return localizedNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLocalizedName(speciesCode, locale) {
|
async function getLocalizedName(speciesCode, locale) {
|
||||||
return eBird.ref.taxonomy.ebird({
|
const cacheKey = `sppnames-${locale}-${speciesCode}`;
|
||||||
fmt: 'json',
|
const cached = await cache.getCached(cacheKey);
|
||||||
locale: locale,
|
if (cached) {
|
||||||
species: speciesCode
|
return cached;
|
||||||
}).then(
|
} else {
|
||||||
response => {
|
return eBird.ref.taxonomy.ebird({
|
||||||
const names = response[0];
|
fmt: 'json',
|
||||||
debug("Got localized species names");
|
locale: locale,
|
||||||
debugResponses(names);
|
species: speciesCode
|
||||||
return names;
|
}).then(
|
||||||
}
|
response => {
|
||||||
).catch(error => {
|
const names = response[0];
|
||||||
throw error;
|
cache.cacheResponse(cacheKey, names);
|
||||||
});
|
debug("Got localized species names");
|
||||||
|
debugResponses(names);
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
).catch(error => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSpeciesList(regionCode) {
|
async function getSpeciesList(regionCode) {
|
||||||
const cached = await cache.getCached(`spplist-${regionCode}`);
|
const cached = await cache.getCached(`spplist-${regionCode}`);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
if (cached != []) {
|
||||||
|
return cached;
|
||||||
|
} else {
|
||||||
|
console.log("Empty cached species list, retrieving again");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return eBird.product.spplist.in(regionCode)()
|
return eBird.product.spplist.in(regionCode)()
|
||||||
.then(species => {
|
.then(species => {
|
||||||
|
@ -112,10 +161,16 @@ function getAudio(speciesScientificName) {
|
||||||
name: speciesScientificName,
|
name: speciesScientificName,
|
||||||
quality: 'A'
|
quality: 'A'
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
debugResponses(response);
|
|
||||||
const { recordings } = response;
|
const { recordings } = response;
|
||||||
const randomRecord = choice(recordings);
|
let randomRecord;
|
||||||
const audio = randomRecord.file;
|
let audio;
|
||||||
|
for (let i=0; i < 3; i++) {
|
||||||
|
randomRecord = choice(recordings);
|
||||||
|
if ('file' in randomRecord) {
|
||||||
|
audio = randomRecord.file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
return audio;
|
return audio;
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
throw error;
|
throw error;
|
||||||
|
@ -123,5 +178,8 @@ function getAudio(speciesScientificName) {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
generateQuestion,
|
||||||
|
getQuestionCached,
|
||||||
|
cacheQuestion,
|
||||||
generateQuizz
|
generateQuizz
|
||||||
}
|
}
|
|
@ -1,46 +1,16 @@
|
||||||
require('dotenv').config();
|
require("dotenv").config();
|
||||||
const debug = require('debug')('soundbirder:api:region');
|
const debug = require("debug")("soundbirder:api:region");
|
||||||
const cache = require('./cache');
|
const cache = require("./cache");
|
||||||
const axios = require('axios');
|
const axios = require("axios");
|
||||||
|
|
||||||
const OPENCAGE_API_KEY = process.env.OPENCAGE_API_KEY;
|
const { lookUp } = require("geojson-places");
|
||||||
const OPENCAGE_API_URL = 'https://api.opencagedata.com/geocode/v1/json?q=<lat>+<lon>&key=<key>';
|
|
||||||
|
|
||||||
async function getRegion(lat, lon) {
|
async function getRegion(lat, lon) {
|
||||||
const url = OPENCAGE_API_URL
|
const result = lookUp(lat, lon);
|
||||||
.replace('<lat>', lat)
|
const region = result.country_a2;
|
||||||
.replace('<lon>', lon)
|
return region;
|
||||||
.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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
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",
|
"Game": "Spiel",
|
||||||
"About": "About"
|
"About": "About",
|
||||||
|
"It was a": "__It was a"
|
||||||
}
|
}
|
|
@ -1,15 +1,26 @@
|
||||||
{
|
{
|
||||||
"Home": "Home",
|
|
||||||
"About": "About",
|
|
||||||
"Game": "Game",
|
"Game": "Game",
|
||||||
"Contact": "Contact",
|
"About": "About",
|
||||||
"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",
|
"It was a": "It was a",
|
||||||
"Author": "Author",
|
"Language": "Language",
|
||||||
"The project is made with ♥ by Samuel ORTION": "The project is made with ♥ by Samuel ORTION",
|
"Submit": "Submit",
|
||||||
"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.",
|
"Geolocalize yourself on the map": "Geolocalize yourself on the map",
|
||||||
"The project is made with ♥ by Samuel ORTION.": "The project is made with ♥ by Samuel ORTION.",
|
"Start the game": "Start the game",
|
||||||
"Welcome to SoundBirder' API": "Welcome to SoundBirder' API",
|
"Start a new quizz": "Start a new quizz",
|
||||||
|
"Return to the map": "Return to the map",
|
||||||
"Wrong!": "Wrong!",
|
"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!",
|
"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",
|
"Home": "Inicio",
|
||||||
"About": "Acerca de"
|
"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",
|
"Game": "Jeu",
|
||||||
"About": "À propos",
|
"About": "À propos",
|
||||||
"Contact": "Contact",
|
"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",
|
"Author": "Auteur",
|
||||||
"The project is made with ♥ by Samuel ORTION": "Ce projet est fait avec ♥ par Samuel ORTION",
|
"The project is made with ♥ by Samuel Ortion.": "Ce projet est fait avec ♥ par Samuel Ortion.",
|
||||||
"Correct!": "Correct!",
|
"Correct!": "Correct !",
|
||||||
"Wrong!": "Incorrect!",
|
"Wrong!": "Incorrect !",
|
||||||
"It was a": "C'était un"
|
"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",
|
"name": "soundbirder",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
|
@ -11,6 +11,7 @@
|
||||||
"@unclesamulus/xeno-canto-api": "^0.0.0",
|
"@unclesamulus/xeno-canto-api": "^0.0.0",
|
||||||
"autoprefixer": "^10.4.8",
|
"autoprefixer": "^10.4.8",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
"codegrid-js": "github:hlaw/codegrid-js",
|
||||||
"connect-redis": "^6.1.3",
|
"connect-redis": "^6.1.3",
|
||||||
"cookie-parser": "~1.4.4",
|
"cookie-parser": "~1.4.4",
|
||||||
"csurf": "^1.11.0",
|
"csurf": "^1.11.0",
|
||||||
|
@ -19,13 +20,18 @@
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"feather-icons": "^4.29.0",
|
"feather-icons": "^4.29.0",
|
||||||
|
"geojson-places": "^1.0.8",
|
||||||
"http-errors": "~1.6.3",
|
"http-errors": "~1.6.3",
|
||||||
"i18n-2": "^0.7.3",
|
"i18n-2": "^0.7.3",
|
||||||
"leaflet": "^1.8.0",
|
"leaflet": "^1.8.0",
|
||||||
|
"local-reverse-geocoder": "^0.16.7",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"morgan": "~1.9.1",
|
"morgan": "~1.9.1",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"postcss-cli": "^11.0.0",
|
||||||
"pug": "^3.0.2",
|
"pug": "^3.0.2",
|
||||||
"redis": "^4.3.0",
|
"redis": "^4.6.13",
|
||||||
"tailwindcss": "^3.1.8"
|
"tailwindcss": "^3.1.8",
|
||||||
|
"util": "^0.12.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
const API_VERSION = "0";
|
const API_URL = '/api';
|
||||||
const API_URL = `/api/${API_VERSION}`;
|
|
||||||
|
|
||||||
const TOKEN = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
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);
|
return await get(`${API_URL}/${endpoint}`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getQuizz(lat, lng) {
|
function getQuestion(region) {
|
||||||
return query('game/quizz', { lat, lng })
|
return query('game/question', { region })
|
||||||
.then(data => {
|
|
||||||
return data;
|
|
||||||
}).catch(error => {
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkAnswer(speciesId) {
|
function getQuizz(region) {
|
||||||
return query(`game/check`, {
|
return query('game/quizz', { region })
|
||||||
species: speciesId
|
|
||||||
}).then(data => {
|
|
||||||
return data;
|
|
||||||
}).catch(error => {
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 = {
|
const client = {
|
||||||
|
getQuestion,
|
||||||
getQuizz,
|
getQuizz,
|
||||||
|
getRegion,
|
||||||
checkAnswer
|
checkAnswer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,55 +1,94 @@
|
||||||
import { geolocationHandler, getCoordinates } from './map.js';
|
import { geolocationHandler, getCoordinates } from './map.js';
|
||||||
import client from './api-client.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() {
|
function geolocationStep() {
|
||||||
if (document.getElementById('map') != undefined)
|
if (map != undefined)
|
||||||
geolocationHandler();
|
geolocationHandler();
|
||||||
let start_button = document.querySelector('.game .start-button');
|
if (startButton != undefined)
|
||||||
if (start_button != undefined)
|
startButton.addEventListener('click', regionCoder);
|
||||||
start_button.addEventListener('click', quizzStep);
|
}
|
||||||
|
|
||||||
|
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() {
|
function quizzStep() {
|
||||||
// Start by disallowing geolocation step
|
gameQuizz.classList.remove("none");
|
||||||
document.querySelector('.game-map-step').classList.toggle('none');
|
gameLoading.classList.remove("none");
|
||||||
// Then allow the quizz step
|
audio.classList.add("none");
|
||||||
document.querySelector('.game-quizz-step').classList.remove('none');
|
client.getQuestion(region)
|
||||||
|
|
||||||
// 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)
|
|
||||||
.then(quizz => {
|
.then(quizz => {
|
||||||
// Display the quizz
|
gameLoading.classList.add("none");
|
||||||
displayQuizz(quizz);
|
displayQuizz(quizz);
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function displayQuizz(quizz) {
|
function displayQuizz(quizz) {
|
||||||
let audio = document.querySelector('.game-quizz-step audio');
|
if (quizz.audio == undefined) {
|
||||||
|
quizzStep();
|
||||||
|
return;
|
||||||
|
}
|
||||||
audio.src = quizz.audio;
|
audio.src = quizz.audio;
|
||||||
audio.play();
|
audio.classList.remove("none"); // Display the audio controls
|
||||||
let proposals = document.querySelector('.game-quizz-step .proposals');
|
gameQuizzStep.classList.remove("none");
|
||||||
|
// audio.play();
|
||||||
|
proposals.innerHTML = "";
|
||||||
quizz.species.forEach(sp => {
|
quizz.species.forEach(sp => {
|
||||||
|
if (sp.speciesCode == undefined) {
|
||||||
|
quizzStep();
|
||||||
|
return
|
||||||
|
}
|
||||||
let proposal = document.createElement('li');
|
let proposal = document.createElement('li');
|
||||||
proposal.classList.add('proposal');
|
proposal.classList.add('proposal');
|
||||||
let button = document.createElement('button');
|
let button = document.createElement('button');
|
||||||
button.classList.add('proposal-button');
|
button.classList.add('proposal-button');
|
||||||
button.classList.add('button');
|
button.classList.add('button');
|
||||||
button.value = sp.speciesCode;
|
button.value = sp.speciesCode;
|
||||||
button.innerText = sp.comName;
|
button.innerHTML = `${sp.comName} (<i>${sp.sciName}</i>)`;
|
||||||
proposal.appendChild(button);
|
proposal.appendChild(button);
|
||||||
proposals.appendChild(proposal);
|
proposals.appendChild(proposal);
|
||||||
button.addEventListener('click', verifyAnswer);
|
button.addEventListener('click', verifyAnswer);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyAnswer(event) {
|
function verifyAnswer(event) {
|
||||||
let answer = event.target.value;
|
let answer = event.target.value;
|
||||||
|
console.log(answer);
|
||||||
|
audio.pause();
|
||||||
client.checkAnswer(answer)
|
client.checkAnswer(answer)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
let message_class;
|
let message_class;
|
||||||
|
@ -62,21 +101,23 @@ function verifyAnswer(event) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
restartButton.addEventListener('click', restart);
|
||||||
|
mapButton.addEventListener('click', returnToMap);
|
||||||
|
|
||||||
function displayResult(message_class, message, species) {
|
function displayResult(message_class, message, species) {
|
||||||
let result = document.querySelector('.game-results-step .message');
|
gameQuizzStep.classList.toggle('none');
|
||||||
document.querySelector('.game-quizz-step').classList.toggle('none');
|
|
||||||
result.classList.remove('success', 'error');
|
result.classList.remove('success', 'error');
|
||||||
result.classList.add(message_class);
|
result.classList.add(message_class);
|
||||||
result.innerText = message;
|
result.innerText = message;
|
||||||
document.querySelector('.game-results-step').classList.remove('none');
|
gameResultStep.classList.remove('none');
|
||||||
document.querySelector('.game-results-step .restart-button').addEventListener('click', restart);
|
resultComName.innerText = species.comName;
|
||||||
document.querySelector('.game-results-step .species .com').innerText = species.comName;
|
resultSciName.innerText = species.sciName;
|
||||||
document.querySelector('.game-results-step .species .sci').innerText = species.sciName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function restart() {
|
function restart() {
|
||||||
window.location.reload();
|
gameResultStep.classList.add("none");
|
||||||
|
gameMapStep.classList.add("none");
|
||||||
|
quizzStep();
|
||||||
}
|
}
|
||||||
|
|
||||||
function game() {
|
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");
|
let lng = getCookie("lng");
|
||||||
if (lat != undefined && lng != undefined) {
|
if (lat != undefined && lng != undefined) {
|
||||||
location = [lat, lng];
|
location = [lat, lng];
|
||||||
console.log(`Got a previous geolocation cookie at ${location[0]}, ${location[1]}`)
|
|
||||||
}
|
}
|
||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
|
@ -21,7 +20,7 @@ function geolocationHandler() {
|
||||||
let location = getCoordinates();
|
let location = getCoordinates();
|
||||||
let message = document.querySelector('.message');
|
let message = document.querySelector('.message');
|
||||||
// Init map
|
// 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', {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
}).addTo(map);
|
}).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 {
|
.link {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
@ -29,10 +33,12 @@
|
||||||
footer .link:after {
|
footer .link:after {
|
||||||
border-bottom: 2px dotted white;
|
border-bottom: 2px dotted white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#map {
|
#map {
|
||||||
height: 50vh;
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invit {
|
||||||
|
min-height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -50,4 +56,12 @@ footer .link:after {
|
||||||
|
|
||||||
.proposal-button {
|
.proposal-button {
|
||||||
margin: 1em 0;
|
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)
|
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)
|
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.
|
2. Prevent adjustments of font size after orientation changes in iOS.
|
||||||
3. Use a more readable tab size.
|
3. Use a more readable tab size.
|
||||||
4. Use the user's configured `sans` font-family by default.
|
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 {
|
html {
|
||||||
|
@ -33,6 +35,8 @@ html {
|
||||||
-o-tab-size: 4;
|
-o-tab-size: 4;
|
||||||
tab-size: 4; /* 3 */
|
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-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,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
font-family: inherit; /* 1 */
|
font-family: inherit; /* 1 */
|
||||||
|
font-feature-settings: inherit; /* 1 */
|
||||||
|
font-variation-settings: inherit; /* 1 */
|
||||||
font-size: 100%; /* 1 */
|
font-size: 100%; /* 1 */
|
||||||
font-weight: inherit; /* 1 */
|
font-weight: inherit; /* 1 */
|
||||||
line-height: inherit; /* 1 */
|
line-height: inherit; /* 1 */
|
||||||
|
@ -300,6 +306,13 @@ menu {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Reset default styling for dialogs.
|
||||||
|
*/
|
||||||
|
dialog {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Prevent resizing textareas horizontally by default.
|
Prevent resizing textareas horizontally by default.
|
||||||
*/
|
*/
|
||||||
|
@ -368,6 +381,11 @@ video {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Make elements with the HTML hidden attribute stay hidden by default */
|
||||||
|
[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
*, ::before, ::after {
|
*, ::before, ::after {
|
||||||
--tw-border-spacing-x: 0;
|
--tw-border-spacing-x: 0;
|
||||||
--tw-border-spacing-y: 0;
|
--tw-border-spacing-y: 0;
|
||||||
|
@ -382,53 +400,9 @@ video {
|
||||||
--tw-pan-y: ;
|
--tw-pan-y: ;
|
||||||
--tw-pinch-zoom: ;
|
--tw-pinch-zoom: ;
|
||||||
--tw-scroll-snap-strictness: proximity;
|
--tw-scroll-snap-strictness: proximity;
|
||||||
--tw-ordinal: ;
|
--tw-gradient-from-position: ;
|
||||||
--tw-slashed-zero: ;
|
--tw-gradient-via-position: ;
|
||||||
--tw-numeric-figure: ;
|
--tw-gradient-to-position: ;
|
||||||
--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-ordinal: ;
|
--tw-ordinal: ;
|
||||||
--tw-slashed-zero: ;
|
--tw-slashed-zero: ;
|
||||||
--tw-numeric-figure: ;
|
--tw-numeric-figure: ;
|
||||||
|
@ -476,6 +450,9 @@ video {
|
||||||
--tw-pan-y: ;
|
--tw-pan-y: ;
|
||||||
--tw-pinch-zoom: ;
|
--tw-pinch-zoom: ;
|
||||||
--tw-scroll-snap-strictness: proximity;
|
--tw-scroll-snap-strictness: proximity;
|
||||||
|
--tw-gradient-from-position: ;
|
||||||
|
--tw-gradient-via-position: ;
|
||||||
|
--tw-gradient-to-position: ;
|
||||||
--tw-ordinal: ;
|
--tw-ordinal: ;
|
||||||
--tw-slashed-zero: ;
|
--tw-slashed-zero: ;
|
||||||
--tw-numeric-figure: ;
|
--tw-numeric-figure: ;
|
||||||
|
@ -515,6 +492,7 @@ h1 {
|
||||||
color: var(--tw-main-color);
|
color: var(--tw-main-color);
|
||||||
}
|
}
|
||||||
.button {
|
.button {
|
||||||
|
margin: 0.5rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
|
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
|
||||||
|
@ -530,41 +508,420 @@ h1 {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
|
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 {
|
.block {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
.h-10 {
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
.h-full {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
.min-h-screen {
|
.min-h-screen {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
.w-10 {
|
||||||
|
width: 2.5rem;
|
||||||
|
}
|
||||||
.w-full {
|
.w-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.min-w-\[100px\] {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
.flex-1 {
|
.flex-1 {
|
||||||
flex: 1 1 0%;
|
flex: 1 1 0%;
|
||||||
}
|
}
|
||||||
|
.select-none {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
.flex-row {
|
.flex-row {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
.flex-col {
|
.flex-col {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.justify-evenly {
|
.justify-between {
|
||||||
justify-content: space-evenly;
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.rounded-\[7px\] {
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.border {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
.border-t-transparent {
|
||||||
|
border-top-color: transparent;
|
||||||
}
|
}
|
||||||
.bg-black {
|
.bg-black {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
|
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
.bg-transparent {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
.p-1 {
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
.p-5 {
|
.p-5 {
|
||||||
padding: 1.25rem;
|
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-center {
|
||||||
text-align: 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 {
|
.text-white {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
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 {
|
.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 redis = require('redis');
|
||||||
|
const util = require('util');
|
||||||
|
|
||||||
const redisHost = process.env.REDIS_HOST ? process.env.REDIS_HOST : 'localhost';
|
const redisHost = process.env.REDIS_HOST ? process.env.REDIS_HOST : 'localhost';
|
||||||
const redisPort = process.env.REDIS_PORT ? process.env.REDIS_PORT : 6379;
|
const redisPort = process.env.REDIS_PORT ? process.env.REDIS_PORT : 6379;
|
||||||
|
|
||||||
const url = `redis://${redisHost}:${redisPort}`;
|
const url = `redis://${redisHost}:${redisPort}`;
|
||||||
const redisClient = redis.createClient({
|
const redisClient = redis.createClient({
|
||||||
url,
|
url,
|
||||||
legacyMode: true
|
legacyMode: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
redisClient.pget = util.promisify(redisClient.get);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
redisClient.connect()
|
redisClient.connect().catch(console.error)
|
||||||
})();
|
})();
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|
|
@ -2,8 +2,9 @@ const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const apiController = require('../controllers/api.js');
|
const apiController = require('../controllers/api.js');
|
||||||
|
|
||||||
router.route('/')
|
|
||||||
.get(apiController.getHome);
|
router.route('/game/question')
|
||||||
|
.get(apiController.game.question);
|
||||||
|
|
||||||
router.route('/game/quizz')
|
router.route('/game/quizz')
|
||||||
.get(apiController.game.quizz);
|
.get(apiController.game.quizz);
|
||||||
|
@ -11,4 +12,7 @@ router.route('/game/quizz')
|
||||||
router.route('/game/check')
|
router.route('/game/check')
|
||||||
.get(apiController.game.check);
|
.get(apiController.game.check);
|
||||||
|
|
||||||
|
router.route('/region')
|
||||||
|
.get(apiController.region);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
|
@ -5,6 +5,9 @@ const indexController = require('../controllers/index');
|
||||||
router.route('/')
|
router.route('/')
|
||||||
.get(indexController.getIndex);
|
.get(indexController.getIndex);
|
||||||
|
|
||||||
|
router.route('/quizz')
|
||||||
|
.get(indexController.getQuizz);
|
||||||
|
|
||||||
router.route('/about')
|
router.route('/about')
|
||||||
.get(indexController.getAbout);
|
.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
|
extends layout.pug
|
||||||
|
|
||||||
block content
|
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.") }
|
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.") }
|
|
||||||
|
|
||||||
|
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
|
||||||
.game-map-step
|
.game-map-step
|
||||||
#map.h-100
|
.invit
|
||||||
button.button.geolocation-button
|
#map
|
||||||
i(data-feather="map-pin")
|
.controls
|
||||||
button.button.start-button
|
button.button.geolocation-button(aria-label=__('Geolocalize yourself on the map'))
|
||||||
i(data-feather="play")
|
i(data-feather="map-pin")
|
||||||
.game-quizz-step.none
|
button.button.start-button(aria-label=__('Start the game'))
|
||||||
ul.proposals
|
i(data-feather="play")
|
||||||
audio
|
button.button.quizz-start-button(aria-label=__('Launch a quizz'))
|
||||||
.game-results-step.none
|
i(data-feather="clipboard")
|
||||||
p.message
|
.game-quizz.none
|
||||||
.species.answer
|
.invit
|
||||||
p #{ __('It was a') }
|
.game-loading
|
||||||
span.com
|
<svg class="spinner" width="65px" height="65px" viewBox="0 0 66 66" xmlns="http://www.w3.org/2000/svg">
|
||||||
span.sci
|
<circle class="path" fill="none" stroke-width="6" stroke-linecap="round" cx="33" cy="33" r="30"></circle>
|
||||||
button.button.restart-button
|
</svg>
|
||||||
i(data-feather="repeat")
|
.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="/dist/leaflet/leaflet.css")
|
||||||
|
link(rel="stylesheet" href="/stylesheets/spinner.css")
|
||||||
script(src="/dist/leaflet/leaflet.js")
|
script(src="/dist/leaflet/leaflet.js")
|
||||||
script(src="/dist/axios/axios.min.js")
|
script(src="/dist/axios/axios.min.js")
|
||||||
script(src="/javascripts/game.js" type="module")
|
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
|
doctype html
|
||||||
html
|
html(lang=locale)
|
||||||
head
|
head
|
||||||
title= title
|
title= title
|
||||||
meta(name="viewport" content="width=device-width, initial-scale=1.0")
|
meta(name="viewport" content="width=device-width, initial-scale=1.0")
|
||||||
|
@ -10,21 +10,27 @@ html
|
||||||
body
|
body
|
||||||
.flex.flex-col.min-h-screen
|
.flex.flex-col.min-h-screen
|
||||||
.flex-1.p-5
|
.flex-1.p-5
|
||||||
nav
|
.menu.justify-between
|
||||||
- var i18n_prefix = locale ? '/' + locale : ''
|
nav(role="navigation").flex.flex-row
|
||||||
ul.flex.flex-row.text-center.justify-evenly
|
- var i18n_prefix = locale ? '/' + locale : ''
|
||||||
li
|
span.nav-item
|
||||||
a(href=`${i18n_prefix}/`) #{ __("Game") }
|
a(href=`${i18n_prefix}/` title=__('Run a question')) #{ __('Game') }
|
||||||
li
|
span.nav-item
|
||||||
a(href=`${i18n_prefix}/about`) #{ __("About") }
|
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
|
header
|
||||||
h1= title
|
h1= title
|
||||||
main
|
main.flex-1
|
||||||
block content
|
block content
|
||||||
footer.w-full.bg-black.text-white.text-center.p-5
|
footer.w-full.bg-black.text-white.text-center.p-5
|
||||||
.description
|
.description
|
||||||
.copyright Copyright © 2022 -
|
.copyright
|
||||||
|
a(href="https://www.gnu.org/licenses/agpl-3.0.en.html") 🄯
|
||||||
|
| 2022 - by
|
||||||
span.author
|
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="/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