diff --git a/.sqlx/query-630981b7637ee724554aa5a4ad1f49453766684f9cdf724178c9320a12a56bac.json b/.sqlx/query-630981b7637ee724554aa5a4ad1f49453766684f9cdf724178c9320a12a56bac.json new file mode 100644 index 0000000..063d733 --- /dev/null +++ b/.sqlx/query-630981b7637ee724554aa5a4ad1f49453766684f9cdf724178c9320a12a56bac.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT id name FROM players", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "630981b7637ee724554aa5a4ad1f49453766684f9cdf724178c9320a12a56bac" +} diff --git a/Cargo.toml b/Cargo.toml index b1162b5..a35dde3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,12 +7,17 @@ edition = "2021" [dependencies] tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"]} -hyper = { version = "1.4.1", features = ["server", "http1"] } +hyper = { version = "1.4.1", features = ["server", "http1", "http2"] } hyper-util = { version = "0.1.8", features = ["tokio"]} -sqlx = { version = "0.8.2", features = ["runtime-tokio"] } +sqlx = { version = "0.8.2", features = ["runtime-tokio", "sqlx-sqlite", "sqlite", "sqlx-macros"] } tokio-io = "0.2.0-alpha.6" dotenvy = "0.15.7" bytes = "1.7.1" http-body-util = "0.1.2" +serde = "1.0.210" serde_json = "1.0.128" -log = "0.4.22" \ No newline at end of file +log = "0.4.22" +futures = "0.3.30" +chrono = { version = "0.4.38", features = ["alloc"]} +futures-util = "0.3.30" +h2 = "0.4.6" \ No newline at end of file diff --git a/migrations/20240911124945_drop_votes_convert.sql b/migrations/20240911124945_drop_votes_convert.sql new file mode 100644 index 0000000..cf672d1 --- /dev/null +++ b/migrations/20240911124945_drop_votes_convert.sql @@ -0,0 +1,2 @@ +-- Add migration script here +drop table if exists votes \ No newline at end of file diff --git a/migrations/20240911125144_votes_convert.sql b/migrations/20240911125144_votes_convert.sql new file mode 100644 index 0000000..c753a12 --- /dev/null +++ b/migrations/20240911125144_votes_convert.sql @@ -0,0 +1,11 @@ +create table if not exists votes +( +id integer primary key not null, +timestamp text not null, +plus_id integer not null, +plus_nickname text not null, +plus_reason text not null, +moins_id integer not null, +moins_nickname text not null, +moins_reason text not null +) diff --git a/routes.json b/routes.json index fb74a8f..53057cb 100644 --- a/routes.json +++ b/routes.json @@ -1,3 +1,5 @@ { - "/": "static/html/index.html" + "/": "static/html/index.html", + "/results": "static/html/results.html", + "/archives": "static/html/archives.html" } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a991a8d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,235 @@ +extern crate core; + +use bytes::Bytes; +use bytes::Buf; +use chrono::{DateTime, Utc}; +use dotenvy::dotenv; +use futures; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Error, Method, Request, Response, StatusCode}; +use hyper_util::rt::{TokioIo, TokioTimer}; +use serde::{Deserialize, Serialize}; +use serde_json::{from_reader, Value}; +use sqlx::sqlite::SqlitePool; +use std::collections::HashMap; +use std::env; +use std::env::vars; +use std::fs::File; +use std::io::Read; +use std::net::SocketAddr; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; +use tokio::net::TcpListener; + +#[derive(Serialize, Deserialize)] +struct Player { + id: i64, + name: String, +} + +#[derive(Serialize, Deserialize, Debug)] +struct Vote { + vote_plus_id: i64, + vote_plus_nickname: String, + vote_plus_reason: String, + vote_moins_id: i64, + vote_moins_nickname: String, + vote_moins_reason: String, +} +async fn service(req: Request, db: Arc>) -> Result>, Error> { + match req.method() { + &Method::GET => { get(req,db).await } + &Method::POST => { + post(req, db).await + } + _ => { Ok(Response::new(Full::new(Bytes::from("This method is unimplemented")))) } + } +} + +async fn get(req: Request, db: Arc>) -> Result>, Error> { + let path = req.uri().path(); + if path.starts_with("/static/") { + get_file(path).await + } else if path.starts_with("/data/") { + get_data(path, &req, db).await + } else { + get_page(path).await + } +} + +async fn get_page(path: &str) -> Result>, Error> { + let uri_map_path = find_in_vars("ROUTES_FILE"); + let file = File::open(uri_map_path).expect("Could not find routes"); + let map: Value = from_reader(file).expect("Could not read data"); + match &map[path] { + Value::String(s) => get_file(s.as_str()).await, + _ => not_found().await + } +} + +async fn get_file(path: &str) -> Result>, Error> { + let current_dir = env::current_dir().unwrap().clone(); + let mut path = path; + if path.starts_with(r"/") { + path = path.strip_prefix(r"/").unwrap(); + } + let file_path = current_dir.join(path); + + match File::open(&file_path) { + Ok(mut file) => { + let mut buf = Vec::new(); + file.read_to_end(&mut buf).unwrap(); + let header = match file_path.extension().unwrap().to_str().unwrap() { + "js" => "text/javascript", + "html" => "text/html", + "css" => "text/css", + _ => "" + }; + Ok(Response::builder().header("content-type", header).body(Full::new(Bytes::from(buf))).unwrap()) + } + Err(_) => not_found().await + } +} + +async fn get_data(path: &str, req: &Request, db: Arc>) -> Result>, Error> { + let pool = db.clone().lock().unwrap().clone(); + match path { + "/data/players" => { + let items = sqlx::query!(r#"SELECT id, name FROM players"#).fetch_all(&pool).await.unwrap(); + let players: Vec = items.iter().map(|x| Player { id: x.id, name: x.name.clone() }).collect(); + Ok(Response::new(Full::new(Bytes::from(serde_json::to_string(&players).unwrap())))) + } + "/data/votes" => { + let votes = get_votes(req, db).await; + Ok(Response::new(Full::new(Bytes::from(serde_json::to_string(&votes).unwrap())))) + } + "/data/results" => { + let votes = get_votes(req, db).await; + let ids: Vec<[i64; 2]> = votes.iter().map(|x| [x.vote_plus_id, x.vote_moins_id]).collect(); + let mut results: Vec> = Vec::new(); + for i in 0..=1 { + let mut counts = HashMap::new(); + ids.iter().for_each(|x| { + if counts.get(&x[i]).is_none() { + counts.insert(x[i], 0); + } + *counts.get_mut(&x[i]).unwrap() += 1; + }); + results.push(counts); + } + + let mut sorted: Vec> = results.into_iter().map(|hashmap| hashmap.into_iter().collect::>()).collect(); + + sorted.iter_mut().for_each(|x| x.sort_by(|a, b| b.1.cmp(&a.1))); + + Ok(Response::new(Full::new(Bytes::from(serde_json::to_string(&sorted).unwrap())))) + } + _ => not_found().await + } +} + +async fn get_votes(req: &Request, db: Arc>) -> Vec { + let pool = db.clone().lock().unwrap().clone(); + let headers = req.headers(); + let date = match headers.get("Date-to-fetch") { + Some(date) => { + let date = date.to_str().unwrap(); + let parsed_date = date.parse::(); + if parsed_date.is_err() { + None + } else { + DateTime::from_timestamp_millis(parsed_date.unwrap()) + } + } + None => Some(DateTime::from(SystemTime::now())) + }; + if date.is_none() { + return Vec::new(); + } + let items = futures::executor::block_on(async move { + let formatted_date = format!("{}", date.unwrap().format("%d/%m/%Y")); + sqlx::query!(r#"SELECT * FROM votes WHERE timestamp = ?1 ORDER BY id"#, formatted_date).fetch_all(&pool).await.unwrap() + }); + items.iter().map(|x| Vote { + vote_plus_id: x.plus_id, + vote_plus_nickname: x.plus_nickname.clone(), + vote_plus_reason: x.plus_reason.clone(), + vote_moins_id: x.moins_id, + vote_moins_nickname: x.moins_nickname.clone(), + vote_moins_reason: x.moins_reason.clone(), + }).collect() +} + +async fn post(req: Request, db: Arc>) -> Result>, Error> { + let path = req.uri().path(); + if path != "/post" { + return Ok(Response::builder().status(StatusCode::BAD_REQUEST).body(Full::new(Bytes::from("Bad Request"))).unwrap()); + } + let body = req.into_body().collect().await.unwrap(); + let data: Result = serde_json::from_reader(body.aggregate().reader()); + if data.is_err() { + return Ok(Response::builder().status(StatusCode::BAD_REQUEST).body(Full::new(Bytes::from("Bad Request"))).unwrap()) + } + let vote = data.unwrap(); + let timestamp: DateTime = DateTime::from(SystemTime::now()); + let formatted = timestamp.format("%d/%m/%Y").to_string(); + let pool = db.clone().lock().unwrap().clone(); + let mut conn = pool.acquire().await.unwrap(); + let result = sqlx::query!(r#"INSERT INTO votes ( plus_id, plus_nickname, plus_reason, moins_id, moins_nickname, moins_reason, timestamp ) + VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7 )"#, + vote.vote_plus_id, + vote.vote_plus_nickname, + vote.vote_plus_reason, + vote.vote_moins_id, + vote.vote_moins_nickname, + vote.vote_moins_reason, + formatted).execute(&mut *conn).await; + if result.is_err() { + return Ok(Response::builder().status(StatusCode::INTERNAL_SERVER_ERROR).body(Full::new(Bytes::from("Internet Error"))).unwrap()) + } + Ok(Response::builder().body(Full::new(Bytes::from("OK"))).unwrap()) +} + +async fn not_found() -> Result>, Error> { + let current_dir = env::current_dir().unwrap().clone(); + let file_path = current_dir.join("static/html/404.html"); + let mut file = File::open(file_path).unwrap(); + let mut buf = Vec::new(); + file.read_to_end(&mut buf).unwrap(); + Ok(Response::builder().status(StatusCode::NOT_FOUND).body(Full::new(Bytes::from(buf))).unwrap()) +} + +fn find_in_vars(pattern: &str) -> String { + let mut vars = vars(); + let result = vars.find(|x| x.0.contains(pattern)).expect(&format!("Could not find '{}' in environment variables", pattern)); + result.1 +} + +#[tokio::main] +async fn main() { + let _ = dotenv(); + let db_adrr = find_in_vars("DATABASE_ADRR"); + let db_pool = Arc::new(Mutex::new(SqlitePool::connect(&db_adrr).await.unwrap())); + let bind_adrr: SocketAddr = SocketAddr::from_str(find_in_vars("BIND_ADRR").as_str()).expect("Could not parse bind address"); + let listener = TcpListener::bind(bind_adrr).await.expect("Could not bind to address."); + loop { + let (tcp, _) = listener.accept().await.expect("Could not accept stream"); + let io = TokioIo::new(tcp); + let db = db_pool.clone(); + let service = service_fn(move |req| { + service(req, db.clone()) + }); + tokio::task::spawn(async move { + if let Err(err) = http1::Builder::new() + .timer(TokioTimer::new()) + .serve_connection(io, service).await + { + println!("Failed to serve connection: {:?}", err); + } + }); + } +} \ No newline at end of file diff --git a/static/css/404.css b/static/css/404.css new file mode 100644 index 0000000..4305ef3 --- /dev/null +++ b/static/css/404.css @@ -0,0 +1,8 @@ +body { + background-color: #11111b; + text-align: center; +} + +h1, h3, p, a { + color: white; +} \ No newline at end of file diff --git a/static/css/index.css b/static/css/index.css index 43d3907..1fabf13 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -9,6 +9,7 @@ body { position: fixed; bottom: 0; width: 100%; + max-height: 50px; display: flex; background-color: #b4befe; justify-content: center; @@ -21,7 +22,16 @@ body { border-radius: 5px; color: #1e1e2e; font-family: "Segoe UI"; - font-size: 20px; + font-size: 14px; +} + +.app { + margin:auto; + display: flex; + flex-direction: column; + width: 100%; + max-width: 500px; + height: calc(100vh - 60px); } .app > h2 { @@ -30,4 +40,58 @@ body { .app > h1 { color: yellow; +} + +.app > select { + width: 100%; +} + +.app > input { + width: 100%; +} + +.app > textarea { + width: 100%; + height: 100%; + resize: vertical; +} + +.buttons > button { + font-size: 16px; + margin: 4px 0 4px 0; + padding: 5px; + background-color: cornflowerblue; + color: white; + border: none; + border-radius: 5px; +} + +.buttons { + width: 100%; + display: grid; + gap: 5px; + grid-template-columns: repeat(2, 50% [col-start]); +} + +.left { + grid-column-start: 1; + grid-column-end: 2; +} + +.right { + grid-column-start: 2; + grid-column-end: 3; +} +.confirm { + margin: auto; + width: fit-content; + text-align: center; +} + +.confirm > h1 { + color: yellow; +} + +.confirm > h3 { + color: white; } \ No newline at end of file diff --git a/static/css/results.css b/static/css/results.css new file mode 100644 index 0000000..6771ffa --- /dev/null +++ b/static/css/results.css @@ -0,0 +1,77 @@ +body { + background-color: #11111b; + padding:0; + margin:0; +} + +.footer { + margin: 0; + position: fixed; + bottom: 0; + width: 100%; + max-height: 50px; + display: flex; + background-color: #b4befe; + justify-content: center; +} + +.footer > a { + padding: 10px; + margin: 5px; + background-color: #94e2d5; + border-radius: 5px; + color: #1e1e2e; + font-family: "Segoe UI"; + font-size: 14px; +} + +.app { + margin: auto; + height: calc(100vh + 60px); + width: 100%; + max-width: 500px; +} + +.app > p { + color: white; + line-break: strict; +} + +.app > h1 { + color: yellow; +} + +.app > h2 { + color:white; +} + +.app > h3 { + color: white; +} + +.buttons > button { + font-size: 16px; + margin: 4px 0 4px 0; + padding: 5px; + background-color: cornflowerblue; + color: white; + border: none; + border-radius: 5px; +} + +.buttons { + width: 100%; + display: grid; + gap: 5px; + grid-template-columns: repeat(2, 50% [col-start]); +} + +.left { + grid-column-start: 1; + grid-column-end: 2; +} + +.right { + grid-column-start: 2; + grid-column-end: 3; +} diff --git a/static/html/404.html b/static/html/404.html index a88841c..2e9024b 100644 --- a/static/html/404.html +++ b/static/html/404.html @@ -1 +1,18 @@ -

404 NOT FOUND

\ No newline at end of file + + + + + + + + + 404 Not Found + + +

404

+

Not Found Error

+

The page you are looking for does not exists.

+ Return to home page. + + \ No newline at end of file diff --git a/static/html/index.html b/static/html/index.html index 51bd227..61f336a 100644 --- a/static/html/index.html +++ b/static/html/index.html @@ -1,12 +1,14 @@ - - + + + VOTE! - +
diff --git a/static/html/results.html b/static/html/results.html new file mode 100644 index 0000000..36da9d7 --- /dev/null +++ b/static/html/results.html @@ -0,0 +1,21 @@ + + + + + + + + Résultats + + + + +
+ + + \ No newline at end of file diff --git a/static/js/index.js b/static/js/index.js index 9007585..506a49e 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,17 +1,24 @@ Vote = { vote_plus_id: null, - vote_plus_nickname: null, - vote_plus_reason: null, + vote_plus_nickname: "", + vote_plus_reason: "", vote_moins_id: null, - vote_moins_nickname: null, - vote_moins_reason: null + vote_moins_nickname: "", + vote_moins_reason: "" } -load_page(0) +let _ = load_page(0) + +async function load_page(id) { + if (get_state()) { + confirm_popup(); + return; + } + let players = await fetch("data/players").then(r => r.json()); -function load_page(id) { const header = document.createElement("h1"); const buttons = document.createElement("div"); + buttons.className = "buttons"; const vote_id = document.createElement("h2"); vote_id.textContent = "Pour qui votes-tu?" @@ -20,11 +27,19 @@ function load_page(id) { vote_nickname.textContent = "As-tu un surnom à lui donner?"; const vote_nickname_input = document.createElement("input"); vote_nickname_input.inputMode = "text"; + vote_nickname_input.maxLength = 100; const vote_reason = document.createElement("h2"); vote_reason.textContent = "Pourquoi votes-tu pour lui?"; const vote_reason_input = document.createElement("textarea"); vote_reason_input.maxLength = 1000; + players.forEach((player) => { + const player_option = document.createElement("option"); + player_option.value = player["id"]; + player_option.innerText = player["name"]; + vote_id_select.appendChild(player_option); + }) + if (id) { vote_id_select.value = Vote.vote_moins_id; vote_nickname_input.value = Vote.vote_moins_nickname; @@ -33,12 +48,24 @@ function load_page(id) { let previous = document.createElement("button"); previous.textContent = "Précédent"; previous.addEventListener("click", () => { - Vote.vote_moins_id = vote_id.value; + Vote.vote_moins_id = vote_id_select.value; Vote.vote_moins_nickname = vote_nickname_input.value; Vote.vote_moins_reason = vote_reason_input.value; - load_page(0)}); + load_page(0) + }); let submit = document.createElement("button"); submit.textContent = "A Voté"; + submit.addEventListener("click", async () => { + Vote.vote_moins_id = vote_id_select.value; + Vote.vote_moins_nickname = vote_nickname_input.value; + Vote.vote_moins_reason = vote_reason_input.value; + if (await send_vote(Vote)) { + confirm_popup(); + save_state(); + } + }) + submit.className = "right"; + previous.className = "left"; buttons.append(previous, submit); } else { vote_id_select.value = Vote.vote_plus_id; @@ -48,15 +75,51 @@ function load_page(id) { let next = document.createElement("button"); next.innerText = "Suivant"; next.addEventListener("click", () => { - Vote.vote_plus_id = vote_id.value; + Vote.vote_plus_id = vote_id_select.value; Vote.vote_plus_nickname = vote_nickname_input.value; Vote.vote_plus_reason = vote_reason_input.value; load_page(1) }); + next.className = "right"; buttons.appendChild(next); } + const div = document.getElementById("app"); div.innerHTML = ""; - div.append(header,vote_id, vote_id_select, vote_nickname, vote_nickname_input, vote_reason, vote_reason_input, buttons); + div.append(header, vote_id, vote_id_select, vote_nickname, vote_nickname_input, vote_reason, vote_reason_input, buttons); +} + +function confirm_popup() { + const div = document.getElementById("app"); + div.innerText = ""; + div.className = "confirm"; + let success = document.createElement("h1"); + success.textContent = "Merci d'avoir voté!" + let sub = document.createElement("h3"); + sub.textContent = "Ton vote a bien été prit en compte!"; + div.append(success, sub); +} +function save_state() { + // create cookie with expiration the next day. + let date = new Date(Date.now()); + date.setDate(date.getDate() + 1); + date.setHours(0, 0, 0, 0); + document.cookie = "hasvoted=true; expires=" + date.toUTCString() +} + +function get_state() { + // get the cookie if exists => already voted. + let cookie = document.cookie; + return cookie.includes("hasvoted=true"); +} +async function send_vote(vote) { + if (vote.vote_plus_id === null || vote.vote_moins_id === null) { + return false; + } + vote.vote_plus_id = parseInt(vote.vote_plus_id, 10); + vote.vote_moins_id = parseInt(vote.vote_moins_id, 10); + let body = JSON.stringify(vote); + let result = await fetch(window.location.href + "post", {method: "POST", body: body}).then(r => r.status); + return result === 200; } \ No newline at end of file diff --git a/static/js/results.js b/static/js/results.js new file mode 100644 index 0000000..08077b4 --- /dev/null +++ b/static/js/results.js @@ -0,0 +1,152 @@ +async function run() { + const votes = await fetch("data/votes").then(r => r.json()); + const players = await fetch("data/players").then(r => r.json()); + let id = 0; + if (votes.length === 0) { + show_no_votes(); + } else { + show_plus(id, votes, players);} + } + +function show_no_votes() { + const app = document.getElementById("app"); + const sorry = document.createElement("h1"); + sorry.textContent = "Désolé..."; + sorry.style.textAlign = "center"; + const expl = document.createElement("h3"); + expl.textContent = "Mais il n'y a pas de votes pour le moment."; + expl.style.textAlign = "center"; + app.append(sorry, expl); +} + +function show_plus(id, votes, players) { + const app = document.getElementById("app"); + app.innerHTML = ""; + let vote = votes[id]; + let player = players[vote["vote_plus_id"] - 1]["name"]; + let nickname = vote["vote_plus_nickname"]; + let reason = vote["vote_plus_reason"]; + let moins = document.createElement("button"); + moins.textContent = "Et en moins..." + moins.addEventListener("click", () => {show_moins(id, votes, players)}) + moins.className = "right"; + + const vote_p = document.createElement("h2"); + if (nickname === "") { + vote_p.innerHTML = `${player}`; + } + else { + vote_p.innerHTML = `${nickname} (${player})` + } + + const vote_r = document.createElement("p"); + vote_r.textContent = reason; + + const head = document.createElement("h1"); + head.innerText = "EN PLUS"; + + const buttons = document.createElement("div"); + buttons.className = "buttons"; + buttons.append(moins) + + app.append(head, vote_p, vote_r, buttons); +} + +function show_moins(id, votes, players) { + const app = document.getElementById("app"); + app.innerHTML = ""; + let vote = votes[id]; + let nickname = vote["vote_moins_nickname"]; + let reason = vote["vote_moins_reason"]; + let player = players[vote["vote_moins_id"] - 1]["name"]; + let next = document.createElement("button"); + if (id === votes.length - 1) { + next.textContent = "Résultats"; + } else { + next.textContent = "Prochain vote"; + } + next.addEventListener("click", () => { + if (id === votes.length - 1) { + let _ = show_results(players) + } else { + show_plus(id+1, votes, players) + } + }) + next.className = "right"; + + const vote_p = document.createElement("h2"); + if (nickname === "") { + vote_p.innerHTML = `${player}`; + } + else { + vote_p.innerHTML = `${nickname} (${player})` + } + + const vote_r = document.createElement("p"); + vote_r.textContent = reason; + + const head = document.createElement("h1"); + head.innerText = "EN MOINS"; + + const buttons = document.createElement("div"); + buttons.className = "buttons"; + buttons.append(next) + + app.append(head, vote_p, vote_r, buttons); +} + +async function show_results(players) { + const app = document.getElementById("app"); + app.innerHTML = "" + const res = document.createElement("h1"); + res.textContent = "Résultats"; + res.style.textAlign = "center"; + app.append(res); + const vp = document.createElement("h1"); + vp.textContent = "En plus:"; + app.append(vp); + let results = await fetch("data/results").then(r => r.json()); + let plus = results[0]; + let moins = results[1]; + let prev_score = null; + let counter = 0; + for (let i = 0; i < plus.length; i++) { + let p = plus[i]; + let player = players[p[0] - 1]["name"]; + let score = p[1]; + if (prev_score == null || score < prev_score) { + counter += 1; + prev_score = score; + const place = document.createElement("h2"); + place.textContent = `En ${counter}${counter === 1 ? "ère" : "ème"} place:`; + app.append(place); + } + const result = document.createElement("h3"); + result.textContent = `${player} avec ${p[1]} votes!`; + app.append(result); + } + const sep = document.createElement("hr"); + app.append(sep); + const vm = document.createElement("h1"); + vm.textContent = "En moins:"; + app.append(vm); + prev_score = null; + counter = 0; + for (let i = 0; i < moins.length; i++) { + let p = moins[i]; + let player = players[p[0] - 1]["name"]; + let score = p[1]; + if (prev_score == null || score < prev_score) { + counter += 1; + prev_score = score; + const place = document.createElement("h2"); + place.textContent = `En ${counter}${counter === 1 ? "ère" : "ème"} place:`; + app.append(place); + } + const result = document.createElement("h3"); + result.textContent = `${player} avec ${score} votes!`; + app.append(result); + } +} + +let _ = run(); \ No newline at end of file diff --git a/vote.db b/vote.db index cca3ffb..a03eed6 100644 Binary files a/vote.db and b/vote.db differ diff --git a/vote.db-shm b/vote.db-shm new file mode 100644 index 0000000..bfbd572 Binary files /dev/null and b/vote.db-shm differ diff --git a/vote.db-wal b/vote.db-wal new file mode 100644 index 0000000..89ca832 Binary files /dev/null and b/vote.db-wal differ