diff --git a/Cargo.lock b/Cargo.lock index ae3db85..b3eece5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -578,7 +578,7 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "fabula_votes_server" -version = "1.0.0" +version = "1.4.1" dependencies = [ "argon2", "blake2", diff --git a/Cargo.toml b/Cargo.toml index 27aca85..298d596 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "fabula_votes_server" license = "MPL-2.0" readme = "README.md" authors = ["trotFunky"] -version = "1.0.0" +version = "1.4.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index dc5d83a..c2e95a2 100644 --- a/README.md +++ b/README.md @@ -7,25 +7,32 @@ vote and who we think wrote them and see some stats ! A list of things that could be implemented/added to the application, some of them are needed for "feature completeness" ! - - [ ] Being able to change from one week to the next - - [ ] Create new weeks for the admin - - [ ] Proper week redirection - - [ ] Add introduction to the weekly truths + - [x] Being able to change from one week to the next + - [x] Create new weeks for the admin + - [x] Proper week redirection + - [ ] Correctly handle non-existing week number + - [x] Add introduction to the weekly truths - [ ] Bundle static assets in the binary - - [ ] Move the databse queries to their own functions + - [ ] Move the database queries to their own functions - [ ] Cache those results - [ ] Centralize Markdown parsing ? - - [ ] Use fairings for the different elements ? + - [x] Use fairings for the different elements + - [ ] Use guards for User calls ? + - [ ] Use SQLite Row ID for User IDs rather than regular IDs, for randomness ? + - [x] Split user from vote data # Dependencies -This project currently uses : +This project currently uses, for the backend : - [Rocket](https://docs.rs/rocket/0.5.1/rocket/), for the web application backend - [SQLX](https://docs.rs/sqlx/0.7.4/sqlx/), for database access (this is only expeceted to be used with SQLite) - [Tera](https://docs.rs/tera/latest/tera/), for templating - [Argon2](https://docs.rs/argon2/latest/argon2/), for password hashing - [Pull_down CMark](https://docs.rs/pulldown-cmark/0.11.0/pulldown_cmark/), for markdown rendering +For the frontend : + - [Chart.js](https://www.chartjs.org/), for rendering the vote graph. + # License The code present in this repository is licensed under the Mozilla Public License 2.0. diff --git a/db/03_create-week-table.sql b/db/03_create-week-table.sql new file mode 100644 index 0000000..1b24dd7 --- /dev/null +++ b/db/03_create-week-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS Weeks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + number INTEGER NOT NULL CHECK (number > 0), + is_last_week INTEGER NOT NULL, + rendered_text VARCHAR NOT NULL, + raw_text VARCHAR NOT NULL +); + +-- This is to upgrade from version 1.0 to 1.1 with an existing database +INSERT INTO Weeks (number, is_last_week, rendered_text, raw_text) VALUES (1, 1, "", ""); diff --git a/db/04_create-tags-tables.sql b/db/04_create-tags-tables.sql new file mode 100644 index 0000000..e7bb714 --- /dev/null +++ b/db/04_create-tags-tables.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS Tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR NOT NULL UNIQUE, + color VARCHAR NOT NULL +); + +CREATE TABLE IF NOT EXISTS TaggedTruths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + truth_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + FOREIGN KEY (truth_id) REFERENCES Truths(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES Tags(id) ON DELETE CASCADE + ); diff --git a/src/auth.rs b/src/auth.rs index 4b9df6c..672b79d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -8,9 +8,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; use argon2::{Argon2, PasswordHash, PasswordVerifier}; use blake2::{Blake2b512, Digest}; use blake2::digest::FixedOutput; -use sqlx::Error; -use crate::database_records::{AuthTokens, PlayerLoginInfo, Vote}; -use crate::database; +use rocket::fairing::AdHoc; +use crate::database_records::{AuthTokens, PlayerLoginInfo}; +use crate::{database, week}; use database::Db; // TODO: Make FromRequest guard https://api.rocket.rs/v0.5/rocket/request/trait.FromRequest and split admin @@ -21,11 +21,9 @@ pub struct User { pub is_admin: bool, pub id: u16, pub name: String, - pub has_week_vote: bool, - pub votes: Vec } -pub async fn get_user(week: u8, db: &mut Connection, cookies: &CookieJar<'_>) -> User { +pub async fn get_user(db: &mut Connection, cookies: &CookieJar<'_>) -> User { let auth_token: Option = match cookies.get_private("auth_token") { Some(cookie) => Some(cookie.value().to_string()), None => None @@ -57,7 +55,7 @@ pub async fn get_user(week: u8, db: &mut Connection, cookies: &CookieJar<'_> Vec::::new() } }; - tokens.iter().find(|auth_token| {println!("Token : {:?}\nCookie : {:?}", auth_token.token, token_str); auth_token.token.eq(&token_str)}).is_some() + tokens.iter().find(|auth_token| {auth_token.token.eq(&token_str)}).is_some() } else { id_str = String::new(); false @@ -87,26 +85,12 @@ pub async fn get_user(week: u8, db: &mut Connection, cookies: &CookieJar<'_> (String::new(), false) }; - let votes: Vec = if logged_in { - sqlx::query_as("SELECT Votes.* FROM Votes JOIN Truths ON Votes.truth_id == Truths.id AND Truths.week == $1 WHERE voter_id == $2 ORDER BY Truths.number;") - .bind(week) - .bind(&id_str) - .fetch_all(&mut ***db).await.unwrap_or_else(|error| { - error!("Error while getting votes : {error}"); - Vec::::new() - }) - } else { - Vec::::new() - }; - if logged_in { User { logged_in, is_admin, id: id_str.parse::().unwrap(), name, - has_week_vote: if votes.is_empty() { false } else { true }, - votes } } else { User { @@ -114,8 +98,6 @@ pub async fn get_user(week: u8, db: &mut Connection, cookies: &CookieJar<'_> is_admin: false, id: 0, name, - has_week_vote: false, - votes } } } @@ -126,8 +108,8 @@ pub struct AuthForm { password: String } -#[post("/login", data="
")] -pub async fn login(form: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { +#[post("//login", data="")] +pub async fn login(week: u8, form: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { let user_search: Result = sqlx::query_as("SELECT id, is_admin, name, pwd_hash FROM Players WHERE name == $1") .bind(&form.name) .fetch_one(&mut **db) @@ -136,7 +118,7 @@ pub async fn login(form: Form, mut db: Connection, cookies: &Cooki if user_search.is_err() { error!("Login failed : invalid user {:?}, err: {:?}", form.name, user_search.err()); cookies.add(("toast_error", "Impossible de se connecter !")); - return Redirect::to(uri!("/")); + return Redirect::to(uri!(week::week(week))); } let new_user = user_search.unwrap(); @@ -144,7 +126,7 @@ pub async fn login(form: Form, mut db: Connection, cookies: &Cooki if password_hash_parse.is_err() { error!("Login failed : could not parse password hash {:?}", password_hash_parse.err()); cookies.add(("toast_error", "Impossible de se connecter !")); - return Redirect::to(uri!("/")); + return Redirect::to(uri!(week::week(week))); } let password_hash = password_hash_parse.unwrap(); @@ -168,7 +150,7 @@ pub async fn login(form: Form, mut db: Connection, cookies: &Cooki Err(error) => { error!("Login failed : coult not store auth token in database : {error}"); cookies.add(("toast_error", "Impossible de se connecter !")); - return Redirect::to(uri!("/")); + return Redirect::to(uri!(week::week(week))); } } @@ -178,24 +160,41 @@ pub async fn login(form: Form, mut db: Connection, cookies: &Cooki Err(err) => { error!("Login failed : invalid password for {:?}\nError : {err}", new_user.name); cookies.add(("toast_error", "Impossible de se connecter !")); - return Redirect::to(uri!("/")); + return Redirect::to(uri!(week::week(week))); } } - Redirect::to(uri!("/")) + Redirect::to(uri!(week::week(week))) } -pub fn bypass_auth_debug(cookies: &CookieJar<'_>) { - if cookies.get_private("auth_token").is_some() { - return - } - let mut hasher = Blake2b512::new(); - hasher.update(b"8"); - hasher.update(SystemTime::now().duration_since(UNIX_EPOCH).expect("Non monotonous time event").as_secs().to_le_bytes()); - let hash = hasher.finalize_fixed().to_ascii_lowercase(); - let hash_str = String::from_utf8_lossy(hash.as_slice()).to_ascii_lowercase(); - cookies.add_private(("auth_token", hash_str.clone())); - cookies.add_private(("auth_id", 8.to_string())); +#[post("//logout")] +pub async fn logout(week: u8, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let auth_token: Option = match cookies.get_private("auth_token") { + Some(cookie) => Some(cookie.value().to_string()), + None => None + }; - println!("Generated hash string : {hash_str}"); -} \ No newline at end of file + // Should not be able to log out ? + if auth_token.is_none() { + return Redirect::to(uri!(week::week(week))) + } + + match sqlx::query("DELETE FROM AuthTokens WHERE token == $1;") + .bind(auth_token) + .execute(&mut **db) + .await { + Ok(_) => debug!("Auth token deletion successful"), + Err(error) => debug!("Auth token could not be removed ({error}), proceeding anyway.") + } + + cookies.remove_private("auth_token"); + cookies.remove_private("auth_id"); + + Redirect::to(uri!(week::week(week))) +} + +pub fn stage() -> AdHoc { + AdHoc::on_ignite("Auth stage", |rocket| async { + rocket.mount("/", routes![login, logout]) + }) +} diff --git a/src/database_records.rs b/src/database_records.rs index 89c116a..2f49061 100644 --- a/src/database_records.rs +++ b/src/database_records.rs @@ -19,21 +19,26 @@ pub struct PlayerLoginInfo { #[derive(sqlx::FromRow, Deserialize, Serialize)] #[serde(crate = "rocket::serde")] pub struct Truth { - id: u32, + pub id: u32, week: u8, number: u8, author_id: u16, rendered_text: String, raw_text: String, + #[sqlx(skip)] + pub tags: Vec, } #[derive(sqlx::FromRow, Deserialize, Serialize)] #[serde(crate = "rocket::serde")] pub struct DisplayTruth { - id: u32, + pub id: u32, + week: u8, number: u8, author_id: u16, rendered_text: String, + #[sqlx(skip)] + pub tags: Vec, } #[derive(sqlx::FromRow, Deserialize, Serialize)] @@ -58,3 +63,20 @@ pub struct VotingData { pub struct AuthTokens { pub token: String, } + +#[derive(sqlx::FromRow, Deserialize, Serialize)] +#[serde(crate = "rocket::serde")] +pub struct Week { + pub number: u8, + pub is_last_week: bool, + pub rendered_text: String, + pub raw_text: String, +} + +#[derive(FromForm)] +#[derive(sqlx::FromRow, Deserialize, Serialize)] +#[serde(crate = "rocket::serde")] +pub struct Tag { + pub name: String, + pub color: String +} diff --git a/src/main.rs b/src/main.rs index 60bfd1f..bf2299c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,102 +1,40 @@ #[macro_use] extern crate rocket; -use rocket::{Rocket, Build, futures}; use rocket::fs::{FileServer, relative}; -use rocket::http::CookieJar; use rocket::response::Redirect; -use rocket::serde::{Serialize, Deserialize, json::Json}; -use rocket_dyn_templates::{Template, context}; +use rocket_dyn_templates::Template; -use rocket_db_pools::{sqlx, sqlx::Row, Database, Connection}; +use rocket_db_pools::Connection; -mod database_records; mod auth; -mod vote; +mod tag; mod truth; +mod vote; +mod week; + mod database; +mod database_records; use database::Db; -use database_records::*; -use auth::User; - -#[get("/")] -async fn week(week_number: u8, mut db: Connection, cookies: &CookieJar<'_>) -> Template { - let user: User = auth::get_user(week_number, &mut db, cookies).await; - - let other_players = if user.logged_in { - match sqlx::query_as("SELECT id, name FROM Players WHERE id <> $1 AND is_admin == 0 ORDER BY name") - .bind(user.id) - .fetch_all(&mut **db).await { - Ok(v) => v, - Err(error) => { - println!("Some error while getting players : {error}"); - Vec::::new() - } - } - } else { - Vec::::new() - }; - - // FIXME : This is fucking *trash* but fucking hell mate - if user.is_admin { - let truths: Vec = match sqlx::query_as("SELECT * FROM Truths WHERE week == $1 ORDER BY number") - .bind(week_number) - .fetch_all(&mut **db).await { - Ok(v) => v, - Err(error) => { - error!("Error while getting truths : {error}"); - Vec::::new() - } - }; - - Template::render("index", context! { - week_number: week_number, - truths: truths, - user: user, - other_players: other_players, - }) - } else { - let truths: Vec = match sqlx::query_as("SELECT id, number, author_id, rendered_text FROM Truths WHERE week == $1 ORDER BY number") - .bind(week_number) - .fetch_all(&mut **db).await { - Ok(v) => v, - Err(error) => { - error!("Error while getting truths : {error}"); - Vec::::new() - } - }; - - Template::render("index", context! { - week_number: week_number, - truths: truths, - user: user, - other_players: other_players, - }) - } -} #[get("/")] async fn index(mut db: Connection) -> Redirect { - let current_week: u8 = match sqlx::query("SELECT max(week) AS last_week FROM Truths;") - .fetch_one(&mut **db).await { - Ok(v) => v.try_get(0).ok().unwrap_or_else(|| 1), // If error, go back to 1 - Err(error) => { - error!("Error while getting current week : {error:?}"); - 1 - } - }; + let current_week: u8 = week::get_last_week(&mut db).await; - Redirect::to(uri!("/", week(week_number = if current_week == 0 {1} else {current_week}))) + Redirect::to(uri!(week::week(week_number = if current_week == 0 {1} else {current_week}))) } #[launch] fn rocket() -> _ { rocket::build() - .mount("/", FileServer::from(relative!("static_files"))) - .mount("/", routes![index, vote::fetch_vote_data, vote::vote, truth::create_truth, truth::edit_truth, week, auth::login]) + .mount("/static/", FileServer::from(relative!("static_files"))) + .attach(auth::stage()) + .attach(week::stage()) + .attach(truth::stage()) + .attach(vote::stage()) + .attach(tag::stage()) + .mount("/", routes![index]) .attach(database::stage()) .attach(Template::fairing()) } - -// TODO: Random Row ID \ No newline at end of file diff --git a/src/tag.rs b/src/tag.rs new file mode 100644 index 0000000..1069e36 --- /dev/null +++ b/src/tag.rs @@ -0,0 +1,295 @@ +use rocket::fairing::AdHoc; +use rocket::form::Form; +use rocket::http::CookieJar; +use rocket::response::Redirect; +use rocket_db_pools::Connection; +use rocket_dyn_templates::{context, Template}; +use sqlx::{Sqlite, QueryBuilder}; +use crate::database::Db; +use crate::database_records::{DisplayTruth, Tag}; + +use crate::auth; +use crate::week; + +pub async fn get_all_tags(db: &mut Connection, cookies: &CookieJar<'_>) -> Vec { + match sqlx::query_as("SELECT name, color FROM Tags") + .fetch_all(&mut ***db) + .await { + Ok(tags) => tags, + Err(error) => { + error!("Database error while fetching tags : {error}"); + cookies.add(("toast_error", "Erreur lors du chargement des tags.")); + Vec::::new() + } + } +} + +pub async fn get_truth_tags(truth_id: u32, db: &mut Connection) -> Vec { + match sqlx::query_as(" + SELECT Tags.name, Tags.color FROM Tags + JOIN TaggedTruths ON TaggedTruths.tag_id == Tags.id AND TaggedTruths.truth_id == $1 + ORDER BY Tags.id;") + .bind(truth_id) + .fetch_all(&mut ***db) + .await { + Ok(tags) => tags, + Err(error) => { + error!("Could not fetch tags for truth {:?} : {error}", truth_id); + Vec::::new() + } + } +} + +#[get("/")] +pub async fn tag_index(mut db: Connection, cookies: &CookieJar<'_>) -> Template { + let user = auth::get_user(&mut db, cookies).await; + + let tags: Vec = get_all_tags(&mut db, cookies).await; + + Template::render("tags/index", context!{ + user: user, + tags: tags + }) +} + +#[get("/filter?")] +pub async fn filtered_by_tags(tags: Vec, mut db: Connection) -> Template { + let last_week = week::get_last_week(&mut db).await; + let filtered_truths: Vec = if tags.len() == 0 { + match sqlx::query_as("SELECT id, week, number, author_id, rendered_text FROM Truths WHERE week <= $1 ORDER BY week, number;") + .bind(last_week) + .fetch_all(&mut **db) + .await { + Ok(all_truths) => all_truths, + Err(error) => { + error!("Error while fetching all truths for the filter : {error}"); + Vec::::new() + } + } + } else { + let mut query_builder: QueryBuilder = QueryBuilder::new(" + SELECT Truths.id, Truths.week, Truths.number, Truths.author_id, Truths.rendered_text FROM Truths + JOIN TaggedTruths ON Truths.id == TaggedTruths.truth_id + JOIN Tags ON Tags.id == TaggedTruths.tag_id AND Tags.name IN ( + "); + let mut separated_args = query_builder.separated(", "); + for tag in &tags { + separated_args.push_bind(tag); + } + + // Now that the comma separated list of strings is built, finish the query normally. + query_builder.push(") WHERE Truths.week <= "); + query_builder.push_bind(last_week); + query_builder.push("GROUP BY Truths.id ORDER BY Truths.week, Truths.number;"); + match query_builder.build_query_as::().fetch_all(&mut **db).await { + Ok(truths) => { + debug!("Got filtered truths by tags"); + truths + }, + Err(error) => { + error!("Error while fetching filtered truths : {error}"); + Vec::::new() + } + } + }; + + let filter_tags: Vec:: = match sqlx::query_as("SELECT name, color FROM Tags;") + .fetch_all(&mut **db) + .await { + Ok(all_tags) => { + all_tags.into_iter().filter(|tag: &Tag| tags.contains(&tag.name)).collect() + }, + Err(error) => { + error!("Error getting tags to show on filter list : {error}"); + Vec::::new() + } + }; + + Template::render("tags/filtered_truths", context!{ + tags: filter_tags, + truths: filtered_truths + }) +} + + +#[post("/create", data="")] +pub async fn create_tag(new_tag: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let user = auth::get_user(&mut db, cookies).await; + if !user.is_admin { + cookies.add(("toast_error", "Vous n'avez pas la permission de créer une étiquette")); + return Redirect::to(uri!("/tags", tag_index)); + } + + match sqlx::query("INSERT INTO Tags (name, color) VALUES ($1, $2);") + .bind(&new_tag.name) + .bind(&new_tag.color) + .execute(&mut **db) + .await { + Ok(affected_lines) => if affected_lines.rows_affected() != 1 { + error!("Did not create tag : {:?}", &new_tag.name); + cookies.add(("toast_error", "L'étiquette n'a pas pû être créer")); + } else { + debug!("Successfully created new tag {:?}", &new_tag.name); + }, + Err(error) => { + error!("Error whilea adding tag {:?} : {error}", &new_tag.name); + cookies.add(("toast_error", "Erreur à la création de l'étiquette")); + } + }; + + Redirect::to(uri!("/tags", tag_index)) +} + +#[post("/delete/")] +pub async fn delete_tag(tag_name: &str, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let user = auth::get_user(&mut db, cookies).await; + if !user.is_admin { + cookies.add(("toast_error", "Vous n'avez pas la permission de supprimer une étiquette")); + return Redirect::to(uri!("/tags", tag_index)); + } + + match sqlx::query("DELETE FROM Tags WHERE name == $1;") + .bind(&tag_name) + .execute(&mut **db) + .await { + Ok(affected_lines) => if affected_lines.rows_affected() != 1 { + error!("Did not remove {tag_name}"); + cookies.add(("toast_error", "L'étiquette n'a pas pû être supprimée.")); + } + else { + debug!("Tag {tag_name} successfully removed.") + }, + Err(error) => { + error!("Error trying to remove {tag_name} : {error}"); + cookies.add(("toast_error", "Erreur lors de la suppression de l'étiquette")); + } + }; + + Redirect::to(uri!("/tags", tag_index)) +} + +#[post("/update/", data="")] +pub async fn update_tag(tag_name: &str, updated_tag: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let user = auth::get_user(&mut db, cookies).await; + if !user.is_admin { + cookies.add(("toast_error", "Vous n'avez pas la permission de modifier une étiquette")); + return Redirect::to(uri!("/tags", tag_index)); + } + + match sqlx::query("UPDATE Tags SET name = $1, color = $2 WHERE name == $3;") + .bind(&updated_tag.name) + .bind(&updated_tag.color) + .bind(&tag_name) + .execute(&mut **db) + .await { + Ok(affected_lines) => if affected_lines.rows_affected() != 1 { + error!("Did not update tag {tag_name}"); + cookies.add(("toast_error", "L'étiquette n'a pas pû être modifiée.")); + } else { + debug!("Updated {tag_name} successfully."); + } + Err(error) => { + error!("Error while updating {tag_name} : {error}"); + cookies.add(("toast_error", "Erreur lors de la modification de l'étiquette.")); + } + }; + + Redirect::to(uri!("/tags", tag_index)) +} + +#[derive(FromForm)] +pub struct TagForm { + name: String, +} + +#[post("//tag/", data="")] +pub async fn tag_truth(week: u8, truth_id: u32, tag_form: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let user = auth::get_user(&mut db, cookies).await; + if !user.is_admin { + cookies.add(("toast_error", "Vous n'avez pas la permission d'ajouter une étiquette")); + return Redirect::to(uri!("/tags", tag_index)); + } + + let error_cookie = || + cookies.add(("toast_error", "Erreur lors de l'ajout de l'étiquette.")); + + let tag_id: u32 = sqlx::query_scalar("SELECT id FROM Tags WHERE name == $1;") + .bind(&tag_form.name) + .fetch_one(&mut **db) + .await.unwrap_or(0); + + if tag_id == 0 { + error_cookie(); + error!("Error while trying to figure tag ID of {:?} to create the relation with {:?}.", &tag_form.name, &truth_id); + return Redirect::to(uri!(week::week(week))); + } + + match sqlx::query("INSERT INTO TaggedTruths (truth_id, tag_id) VALUES ($1, $2);") + .bind(&truth_id) + .bind(&tag_id) + .execute(&mut **db) + .await { + Ok(affected_lines) => if affected_lines.rows_affected() != 1 { + error_cookie(); + error!("Tag relation not added."); + } else { + debug!("Created tag relation with truth {truth_id} and tag {tag_id}"); + } + Err(error) => { + error_cookie(); + error!("Error while trying to add tag relation : {error}"); + } + } + + Redirect::to(uri!(week::week(week))) +} + +#[post("//untag/", data="")] +pub async fn untag_truth(week: u8, truth_id: u32, tag_form: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let user = auth::get_user(&mut db, cookies).await; + if !user.is_admin { + cookies.add(("toast_error", "Vous n'avez pas la permission de retirer une étiquette")); + return Redirect::to(uri!("/tags", tag_index)); + } + + let error_cookie = || + cookies.add(("toast_error", "Erreur lors de la suppression de l'étiquette.")); + + let tag_id: u32 = sqlx::query_scalar("SELECT id FROM Tags WHERE name == $1;") + .bind(&tag_form.name) + .fetch_one(&mut **db) + .await.unwrap_or(0); + + if tag_id == 0 { + error_cookie(); + error!("Error while trying to figure tag ({:?}) ID to remove the relation with {:?}.", &tag_id, &truth_id); + return Redirect::to(uri!(week::week(week))); + } + + match sqlx::query("DELETE FROM TaggedTruths WHERE truth_id == $1 AND tag_id == $2;") + .bind(&truth_id) + .bind(&tag_id) + .execute(&mut **db) + .await { + Ok(affected_lines) => if affected_lines.rows_affected() != 1 { + error_cookie(); + error!("Tag relation not deleted."); + } else { + debug!("Removed tag relation with truth {truth_id} and tag {tag_id}"); + } + Err(error) => { + error_cookie(); + error!("Error while trying to remove tag relation : {error}"); + } + } + + Redirect::to(uri!(week::week(week))) +} + +pub fn stage() -> AdHoc { + AdHoc::on_ignite("Tag stage", |rocket| async { + rocket + .mount("/tags", routes![tag_index, filtered_by_tags, create_tag, update_tag, delete_tag]) + .mount("/", routes![tag_truth, untag_truth]) + }) +} diff --git a/src/truth.rs b/src/truth.rs index dd4c0ea..f3b6add 100644 --- a/src/truth.rs +++ b/src/truth.rs @@ -5,8 +5,9 @@ use rocket::response::Redirect; use rocket_db_pools::{sqlx, Connection}; use pulldown_cmark::{Parser, Options}; +use rocket::fairing::AdHoc; use sqlx::Row; -use crate::{auth, database}; +use crate::{auth, database, week}; #[derive(FromForm)] pub struct TruthUpdateForm { @@ -17,10 +18,10 @@ pub struct TruthUpdateForm { #[post("//edit/", data="")] pub async fn edit_truth(week: u8, truth_number: u8, form: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { - let user = auth::get_user(week, &mut db, cookies).await; + let user = auth::get_user(&mut db, cookies).await; if !user.is_admin { cookies.add(("toast_error", "Vous n'avez pas la permission de changer la vérité.")); - return Redirect::to(uri!("/")); + return Redirect::to(uri!(week::week(week))); } let mut options = Options::empty(); @@ -50,16 +51,16 @@ pub async fn edit_truth(week: u8, truth_number: u8, form: Form, } }; - Redirect::to(uri!("/")) + Redirect::to(uri!(week::week(week))) } -#[post("//new", data="")] +#[post("//new_truth", data="")] pub async fn create_truth(week: u8, form: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { - let user = auth::get_user(week, &mut db, cookies).await; + let user = auth::get_user(&mut db, cookies).await; if !user.is_admin { cookies.add(("toast_error", "Vous n'avez pas la permission d'ajouter de vérité.")); - return Redirect::to(uri!("/")); + return Redirect::to(uri!(week::week(week))); } let truth_number: u8 = match sqlx::query("SELECT max(number) from Truths WHERE week == $1;") @@ -76,7 +77,7 @@ pub async fn create_truth(week: u8, form: Form, if truth_number == 0 { error!("Error while getting max truth number."); cookies.add(("toast_error", "Erreur lors de l'ajout de la vérité...")); - return Redirect::to(uri!("/")); + return Redirect::to(uri!(week::week(week))); } let mut options = Options::empty(); @@ -108,5 +109,11 @@ pub async fn create_truth(week: u8, form: Form, debug!("Truth was successfully added"); - Redirect::to(uri!("/")) + Redirect::to(uri!(week::week(week))) +} + +pub fn stage() -> AdHoc { + AdHoc::on_ignite("Truth stage", |rocket| async { + rocket.mount("/", routes![create_truth, edit_truth]) + }) } diff --git a/src/vote.rs b/src/vote.rs index e5426df..550707c 100644 --- a/src/vote.rs +++ b/src/vote.rs @@ -9,10 +9,32 @@ use rocket::serde::json::Json; use rocket_db_pools::{sqlx, Connection}; -use crate::{auth, database}; -use crate::database::Db; +use crate::{auth, database, week}; use crate::database_records::{Vote, VotingData}; +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct WeeklyUserVotes { + pub has_week_vote: bool, + pub votes: Vec +} + +pub async fn get_weekly_user_votes(week: u8, user: &auth::User, db: &mut Connection) -> WeeklyUserVotes { + let votes: Vec = if user.logged_in && !user.is_admin { + sqlx::query_as("SELECT Votes.* FROM Votes JOIN Truths ON Votes.truth_id == Truths.id AND Truths.week == $1 WHERE voter_id == $2 ORDER BY Truths.number;") + .bind(week) + .bind(user.id) + .fetch_all(&mut ***db).await.unwrap_or_else(|error| { + error!("Error while getting votes : {error}"); + Vec::::new() + }) + } else { + Vec::::new() + }; + + WeeklyUserVotes {has_week_vote: if votes.is_empty() { false } else { true }, votes} +} + #[derive(FromForm)] pub struct VoteForm { truth_votes: HashMap @@ -21,11 +43,11 @@ pub struct VoteForm { #[post("//vote", data="")] pub async fn vote(week: u8, form: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { - let user = auth::get_user(week, &mut db, cookies).await; + let user = auth::get_user(&mut db, cookies).await; if !user.logged_in { cookies.add(("toast_error", "Vous n'avez pas la permission de changer de vote.")); - return Redirect::to(uri!("/")); + return Redirect::to(uri!(week::week(week))); } let filtered_votes = form.truth_votes.iter().filter_map( @@ -38,9 +60,11 @@ pub async fn vote(week: u8, form: Form, } ); + let existing_votes = get_weekly_user_votes(week, &user, &mut db).await; + let mut had_error = false; for (truth_id, voted_id) in filtered_votes { - match user.votes.iter().find(|vote: &&Vote| {vote.truth_id == *truth_id}) { + match existing_votes.votes.iter().find(|vote: &&Vote| {vote.truth_id == *truth_id}) { Some(vote) => { if *voted_id == vote.voted_id { continue; @@ -62,7 +86,7 @@ pub async fn vote(week: u8, form: Form, } None => { debug!("Player {:?} voting {voted_id} for truth {truth_id}", user.id); - // TODO: Find a way to use only one statement ? + // TODO: Find a way to use only one statement ? --> query_many // Cannot all launch and await because all connect to DB match sqlx::query("INSERT INTO Votes (truth_id, voter_id, voted_id) VALUES ($1, $2, $3);") .bind(truth_id) @@ -85,18 +109,20 @@ pub async fn vote(week: u8, form: Form, debug!("Vote successful") } - Redirect::to(uri!("/")) + Redirect::to(uri!(week::week(week))) } #[derive(Serialize, Deserialize)] #[serde(crate = "rocket::serde")] pub struct VoteData { + truth_count: u8, votes: HashMap>, } // TODO: Cache vote count ? Maintain in state ? #[get("//votes", format = "application/json")] -pub async fn fetch_vote_data(week: u8, mut db: Connection) -> Option> { +pub async fn fetch_vote_data(week: u8, mut db: Connection, cookies: &CookieJar<'_>) -> Option> { + let user = auth::get_user(&mut db, cookies).await; let raw_votes: Vec = sqlx::query_as(" SELECT Players.name as votes_for, Truths.number as truth_number, count(*) as votes FROM Votes JOIN Players ON Votes.voted_id == Players.id @@ -110,12 +136,31 @@ pub async fn fetch_vote_data(week: u8, mut db: Connection) -> Opti Vec::::new() }); + let truth_count: u8 = sqlx::query_scalar("SELECT count(id) from Truths WHERE week == $1;") + .bind(week) + .fetch_one(&mut **db) + .await.unwrap_or(0); + + let player_count: u8 = sqlx::query_scalar("SELECT count(id) from Players WHERE is_admin == 0;") + .fetch_one(&mut **db) + .await.unwrap_or(0); + + // Each player should have a truth assigned to them which they cannot vote on. + let max_vote_count = truth_count * (player_count - 1); + let vote_count = raw_votes.iter().fold(0, |count, votes| {count + votes.votes}); - if vote_count < 17 { + // Only show the graph if we have all the votes and this is not the last week. + if !user.is_admin && (max_vote_count == 0 + || vote_count < max_vote_count + || week == sqlx::query_scalar("SELECT number from Weeks WHERE is_last_week == 1;") + .fetch_optional(&mut **db) + .await.unwrap_or(Some(0)).unwrap_or(0)) { return None; } + // The hash map makes storing and retrieving the data really easy, *but* + // it does lose the order of the original array (which is sorted via the SQL). let mut vote_data = HashMap::>::new(); let mut next_truth_number = 1; for raw_vote in raw_votes { @@ -127,7 +172,9 @@ pub async fn fetch_vote_data(week: u8, mut db: Connection) -> Opti let votes_for_player: &mut Vec = match vote_data.entry(raw_vote.votes_for) { Entry::Occupied(existing) => {existing.into_mut()} - Entry::Vacant(vacant) => {vacant.insert(Vec::::new())} + Entry::Vacant(vacant) => { + next_truth_number = 1; // We changed user, reset to 1. + vacant.insert(Vec::::new())} }; // Fill up missing spaces if we are missing any. @@ -139,12 +186,11 @@ pub async fn fetch_vote_data(week: u8, mut db: Connection) -> Opti next_truth_number = raw_vote.truth_number + 1; } - Some(Json(VoteData{votes: vote_data})) + Some(Json(VoteData{truth_count: truth_count, votes: vote_data})) } -// FIXME: pub fn stage() -> AdHoc { - AdHoc::on_ignite("SQLx Stage", |rocket| async { + AdHoc::on_ignite("Vote stage", |rocket| async { rocket.mount("/", routes![vote, fetch_vote_data]) }) } diff --git a/src/week.rs b/src/week.rs new file mode 100644 index 0000000..79608b7 --- /dev/null +++ b/src/week.rs @@ -0,0 +1,246 @@ +use pulldown_cmark::{Options, Parser}; +use rocket::fairing::AdHoc; +use rocket::form::Form; +use rocket::http::CookieJar; +use rocket::response::Redirect; + +use rocket_db_pools::{sqlx, sqlx::Row, Connection}; +use rocket_dyn_templates::{context, Template}; +use sqlx::{Acquire, Executor}; +use crate::{auth, vote}; +use crate::auth::User; +use crate::database::Db; +use crate::database_records::{DisplayTruth, Player, Tag, Truth, Week}; +use crate::tag; +use crate::vote::WeeklyUserVotes; + +pub async fn get_last_week(db: &mut Connection) -> u8 { + match sqlx::query("SELECT number FROM Weeks WHERE is_last_week == 1;") + .fetch_one(&mut ***db).await { + Ok(row) => row.try_get(0).ok().unwrap_or_else(|| 1), // If error, go back to 1 + Err(error) => { + error!("Error while getting current week : {error:?}"); + 1 + } + } +} + +#[get("/")] +pub async fn week(week_number: u8, mut db: Connection, cookies: &CookieJar<'_>) -> Template { + let user: User = auth::get_user(&mut db, cookies).await; + + let other_players = if user.logged_in { + match sqlx::query_as("SELECT id, name FROM Players WHERE id <> $1 AND is_admin == 0 ORDER BY name") + .bind(user.id) + .fetch_all(&mut **db).await { + Ok(v) => v, + Err(error) => { + error!("Some error while getting players : {error}"); + Vec::::new() + } + } + } else { + Vec::::new() + }; + + let week_data: Week = match sqlx::query_as("SELECT number, is_last_week, rendered_text, raw_text FROM Weeks WHERE number == $1") + .bind(week_number) + .fetch_one(&mut **db) + .await { + Ok(week) => week, + Err(error) => { + error!("Error while retrieving week data : {error}"); + Week {number: 0, is_last_week: true, rendered_text: "".to_string(), raw_text: "".to_string() } + } + }; + + let vote_data: WeeklyUserVotes = vote::get_weekly_user_votes(week_number,&user, &mut db).await; + + // FIXME : This is fucking *trash* but fucking hell mate + if user.is_admin { + let mut truths: Vec = match sqlx::query_as("SELECT * FROM Truths WHERE week == $1 ORDER BY number") + .bind(week_number) + .fetch_all(&mut **db).await { + Ok(v) => v, + Err(error) => { + error!("Error while getting truths : {error}"); + Vec::::new() + } + }; + + for truth in &mut truths { + truth.tags = tag::get_truth_tags(truth.id, &mut db).await; + } + + let tags: Vec = tag::get_all_tags(&mut db, cookies).await; + + Template::render("weeks/index", context! { + week_data: week_data, + truths: truths, + user: user, + other_players: other_players, + vote_data: vote_data, + tags: tags + }) + } else { + let mut truths: Vec = match sqlx::query_as("SELECT id, week, number, author_id, rendered_text FROM Truths WHERE week == $1 ORDER BY number") + .bind(week_number) + .fetch_all(&mut **db).await { + Ok(v) => v, + Err(error) => { + error!("Error while getting truths : {error}"); + Vec::::new() + } + }; + + for truth in &mut truths { + truth.tags = tag::get_truth_tags(truth.id, &mut db).await; + } + + Template::render("weeks/index", context! { + week_data: week_data, + truths: truths, + user: user, + other_players: other_players, + vote_data: vote_data + }) + } +} + +#[post("//edit", data="")] +pub async fn update_week(week: u8, raw_intro: Form, + mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let user = auth::get_user(&mut db, cookies).await; + if !user.is_admin { + cookies.add(("toast_error", "Vous n'avez pas la permission de changer la semaine.")); + return Redirect::to(uri!(week(week))); + } + + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_MATH); + options.insert(Options::ENABLE_TABLES); + let markdown_parser = Parser::new_ext(raw_intro.as_str(), options); + + let mut rendered_markdown = String::new(); + pulldown_cmark::html::push_html(&mut rendered_markdown, markdown_parser); + + match sqlx::query("UPDATE Weeks SET raw_text = $1, rendered_text = $2 WHERE number == $3;") + .bind(&raw_intro.as_str()) + .bind(rendered_markdown) + .bind(week) + .fetch_optional(&mut **db) + .await { + Ok(_) => { + debug!("Week successfully updated") + } + Err(error) => { + error!("Error while updating week {week} data : {error}"); + cookies.add(("toast_error", "Il y a eu un problème lors du changement de la semaine")); + } + }; + + Redirect::to(uri!(week(week))) +} + +#[post("//set_last")] +pub async fn set_last_week(week: u8, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let user = auth::get_user(&mut db, cookies).await; + if !user.is_admin { + cookies.add(("toast_error", "Vous n'avez pas la permission de changer la semaine.")); + return Redirect::to(uri!(week(week))); + } + + let add_error_cookie = || + cookies.add(("toast_error", "Erreur lors du changement d'état de la semaine.")); + + let db_transaction_connection = db.begin().await; + if db_transaction_connection.is_err() { + error!("Could not start database transaction for last week change : {:?}", db_transaction_connection.unwrap_err()); + add_error_cookie(); + return Redirect::to(uri!(week(week))); + } + let mut db_transaction = db_transaction_connection.unwrap(); + + // Remove the last flag from other weeks, if set. + match db_transaction.execute("UPDATE Weeks SET is_last_week = 0 WHERE is_last_week == 1;") + .await { + Ok(_) => debug!("Succesfully cleared is_last_week"), + Err(error) => { + error!("Failed to clear last week status : {error}"); + add_error_cookie(); + return Redirect::to(uri!(week(week))); + } + }; + + // We should set one week, if not there's something wrong : rollback. + if match sqlx::query("UPDATE Weeks SET is_last_week = 1 WHERE number == $1;") + .bind(week) + .execute(&mut *db_transaction) + .await { + Ok(result) => result.rows_affected(), + Err(error) => { + error!("Error while setting last week status : {error}"); + 0 + } + } == 1 { + db_transaction.commit().await.unwrap_or_else(|error| { + error!("Error while committing week is last transaction : {error}"); + add_error_cookie(); + }) + } else { + db_transaction.rollback().await.unwrap_or_else(|error| { + error!("Error while rolling back week is last transaction : {error}"); + add_error_cookie(); + }) + } + + Redirect::to(uri!(week(week))) +} + +#[get("//create")] +pub async fn create_week(week: u8, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let user = auth::get_user(&mut db, cookies).await; + if !user.is_admin { + cookies.add(("toast_error", "Vous n'avez pas la permission de changer la semaine.")); + return Redirect::to(uri!(week(week - 1))); + } + + let week_check: Result, sqlx::Error> = sqlx::query_scalar("SELECT number from Weeks WHERE number == $1;") + .bind(week) + .fetch_optional(&mut **db) + .await; + + if week_check.is_err() { + error!("Error while checking week existence : {:?}", week_check.unwrap_err()); + cookies.add(("toast_error", "Erreur en vérifiant que la semaine n'existe pas déjà")); + return Redirect::to(uri!(week(week - 1))); + } + + if week_check.unwrap().is_some() { + debug!("Week {week} already exists, not creating."); + return Redirect::to(uri!(week(week))); + } + + match sqlx::query("INSERT INTO Weeks (number, is_last_week, rendered_text, raw_text) VALUES ($1, 0, \"\", \"\");") + .bind(week) + .execute(&mut **db) + .await { + Ok(_) => { + debug!("Succesfully created new week {week}"); + Redirect::to(uri!(week(week))) + }, + Err(error) => { + error!("Error while creating new week {week} : {error}"); + cookies.add(("toast_error", "Erreur en créant la nouvelle selmaine.")); + Redirect::to(uri!(week(week - 1))) + } + } +} + +pub fn stage() -> AdHoc { + AdHoc::on_ignite("Week stage", |rocket| async { + rocket.mount("/", routes![week, create_week, update_week, set_last_week]) + }) +} diff --git a/static_files/style.css b/static_files/style.css index 7482b04..0721675 100644 --- a/static_files/style.css +++ b/static_files/style.css @@ -2,10 +2,22 @@ display: flex; flex-direction: row; justify-content: space-between; + align-items: center; margin-right: 2em; } -.page_body { +.top_bar_side { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: baseline; +} + +.top_bar_side > * { + padding: 0 0.5em; +} + +.truth_page_body { display: flex; flex-direction: row; justify-content: space-between; @@ -23,14 +35,28 @@ padding-bottom: 2em; } -.truth_editor { - width: 100%; +.individual_truth h3 { + text-align: center; + padding-bottom: 4px; + border-bottom: slategray solid 0.15em; +} + +.individual_truth p { + padding: 0 0.5em; +} + +.editor { + width: calc(100% - 1.25em); /* The width is calculated *inside* the padding, so adjust for it. */ height: 6eM; font-size: large; padding: 0.5em; margin-bottom: 1em; } +.truth_edit_form { + margin-bottom: 1em; +} + .graph { display: flex; flex-direction: column; @@ -47,10 +73,131 @@ top: 25%; } +.week_change:link, .week_change:visited, +.individual_truth > a, .individual_truth > a:link, .individual_truth > a:visited, +nav > a, nav > a:link, nav > a:visited, +h1 > a, h1 > a:link, h1 > a:visited { + color: black; + text-decoration: none; + transition: all .2s ease-in-out; +} + +.week_change:hover, .week_change:focus, +.individual_truth > a:hover, .individual_truth > a:focus, +nav > a:hover, nav > a:focus, +h1 > a:hover, h1 > a:focus { + color: mediumpurple; + text-shadow: 0 2px 2px slategray; +} + +.week_change_hidden { + padding-left: 0.5eM; + padding-right: 0.5eM; +} + +@media (orientation: portrait) { + .truth_list { + width: 60vw; + } + + .graph { + width: 35vw; + } +} + +.tag_list { + display: flex; + flex-flow: column wrap; + align-content: flex-start; + max-height: 40vh; + margin-bottom: 1em; +} + +@media (orientation: portrait) { + .tag_list { + max-height: none; + } +} + +.new_tag { + display: flex; + flex-direction: column; + border: black dashed 1px; + padding: 0.5em; + margin-top: 0.5em; + width: fit-content; +} + +.new_tag > button { + margin-top: 1em; +} + +.tag { + display: flex; + justify-content: center; + margin: 0.25em; + width: fit-content; + text-decoration: none; + text-shadow: + -1px 0 3px black, + 1px 0 3px black, + 0 -1px 3px black, + 0 1px 3px black; + color: white; + padding: 0.25em 0.75em 0.25em 0.25em; + border-bottom-left-radius: 0.4em; + border-top-left-radius: 0.4em; + border-bottom-right-radius: 1.5em 0.8em; + border-top-right-radius: 1.5em 0.8em; + + transition: all .2s ease-in-out; +} + +.tag:hover, .tag:focus { + color: mediumpurple; +} + +.truth_tags { + display: flex; + flex-flow: wrap; +} + +.truth_tags > .tag { /* We want smaller tags, the shadows must follow as well */ + font-size: smaller; + text-shadow: + -1px 0 2px black, + 1px 0 2px black, + 0 -1px 2px black, + 0 1px 2px black; +} + +.truth_tags > form { + display: contents; +} + +.login { + display: flex; + flex-direction: column; +} + +.login > * { + padding-bottom: 0.1em; +} + +.login > label { + align-self: flex-end; +} + +.login > b { + align-self: center; +} + +/* Global styling */ + hr { border: none; - border-top: 2px solid #9ec5fe; - color: #9ec5fe; + border-top: 2px dotted slategray; + color: slategray; overflow: visible; text-align: center; height: 5px; @@ -75,12 +222,9 @@ body > h2 { padding-left: 0.25eM; } -@media (orientation: portrait) { - .truth_list { - width: 60vw; - } - - .graph { - width: 35vw; - } +nav { + height: fit-content; + margin: 0 0.25em; + border-left: 0.15em solid slategray; + border-right: 0.15em solid slategray; } diff --git a/static_files/vote_chart.js b/static_files/vote_chart.js index 50df871..306a01d 100644 --- a/static_files/vote_chart.js +++ b/static_files/vote_chart.js @@ -1,51 +1,67 @@ - -// const names = ["Bystus", "Dory", "Fen", "Lucky", "Nico", "Peran", "trot"] -// -// -// let data = []; -// for (let i = 0; i < 7; i++) { -// data.push({ -// parsing: true, -// label: names[i], -// data: Array.from(keys, () => Math.round(Math.random()*1.33))}) -// } +const limit_ratio = 1.3 +const colors = ['#b6b8fc', '#b6f4fc', '#fcb6cc', '#e0fcb6', '#fcdcb6', '#b6fcc8', '#f0b6fc'] async function main() { - const vote_response = await fetch(document.URL+"/votes"); + let current_url = new URL(document.URL) + current_url.hash = '' // Strip the local part of the URL, for example if we select a Truth + const vote_response = await fetch(current_url.toString()+"/votes"); if (!vote_response.ok) { return; } - const keys = ["Vérité 1", "Vérité 2", "Vérité 3", "Vérité 4", "Vérité 5", "Vérité 6", "Vérité 7"] + let keys = [] let datasets = [] - try { - const vote_data = (await vote_response.json()).votes; - for (let player in vote_data) { + const vote_data = (await vote_response.json()); + for (let player in vote_data.votes) { datasets.push({ parsing: true, label: player, - data: vote_data[player], + data: vote_data.votes[player], }) } - console.log(datasets) + for (let i = 1; i <= vote_data.truth_count; i++) { + keys.push("Vérité " + i) + } } catch (error) { console.error("Failed to parse vote data : \n\t" + error.message); return; } + + // Sort by label to maintain the same graph order, as it goes through a hash map in the backend. + datasets.sort((a, b) => a.label > b.label) + for (let i = 0; i < datasets.length; i++) { + datasets[i].backgroundColor = colors[i % colors.length] + } + const chart_canvas = document.getElementById("vote_chart") let chart + let previous_orientation function create_chart(keys, data) { let main_axis; let aspect_ratio; - if (window.innerWidth > window.innerHeight) { + let orientation + + if (window.innerWidth / window.innerHeight > limit_ratio) { + orientation = "landscape" main_axis = 'x' aspect_ratio = 2 } else { + orientation = "portrait" main_axis = 'y' aspect_ratio = 0.5 } + // Don't re-create the chart for no reason. + if (orientation === previous_orientation) { + console.log("bijour") + return; + } else { + console.log("badour") + } + + previous_orientation = orientation + if ( chart ) { chart.destroy() } @@ -67,6 +83,13 @@ async function main() { y: { stacked: true } + }, + plugins: { + title: { + display: true, + position: 'bottom', + text: 'Répartition des suppositions' + } } } }) @@ -76,7 +99,7 @@ async function main() { create_chart(keys, datasets) } - const orientation_query = matchMedia("screen and (orientation:portrait)"); + const orientation_query = matchMedia(`(aspect-ratio < ${limit_ratio})`); orientation_query.onchange = update_chart_ratio create_chart(keys, datasets) diff --git a/templates/base_page.html.tera b/templates/base_page.html.tera new file mode 100644 index 0000000..c598fa8 --- /dev/null +++ b/templates/base_page.html.tera @@ -0,0 +1,50 @@ + + +{% set title = "Vérités Nova Borealis" %} + + + {{ title }} + + + {% block scripts %} + {% endblock %} + + + +{% block top_bar %} +
+
+

{{ title }}

+ +
+
+ {% block top_bar_side %} +
+ {% if user.logged_in == true %} + + Connecté en tant que {{ user.name }} + + + {% else %} + + {% endif %} +
+ {% endblock %} +
+
+{% endblock %} + +{% block body %} +{% endblock %} + + + diff --git a/templates/editable_truth.tera b/templates/editable_truth.tera deleted file mode 100644 index 0b94cb6..0000000 --- a/templates/editable_truth.tera +++ /dev/null @@ -1,8 +0,0 @@ -
-

Vérité {{ truth.number }}

-

{{ truth.rendered_text | safe }}

-
-
- {% include "truth_editor" %} -
-
diff --git a/templates/index.html.tera b/templates/index.html.tera deleted file mode 100644 index 851acce..0000000 --- a/templates/index.html.tera +++ /dev/null @@ -1,95 +0,0 @@ - - -{% set title = "Vérités Fabula Ultima" %} - - - {{ title }} - - {% if user.logged_in == true and not user.is_admin %} - - {% endif %} - - - - -{# Check if the user has a vote in advance, for readability #} -{% if user.logged_in == true and user.has_week_vote == true%} - {% set has_vote = true %} -{% else %} - {% set has_vote = false %} -{% endif -%} - - -
-

{{ title }}

- {% if user.logged_in == true %} -

Connecté en tant que {{ user.name }}

- {% else %} - - {% endif %} -
- -

Semaine {{ week_number }}

-
-
- {% if user.logged_in == true and user.is_admin == false %} -
- {% endif %} - - {# Truths start at 1 but the array starts at 0 #} - {% set index_delta = 1 %} - {% for truth in truths %} - {# - The truths are in an ordered array, but one of them might be the user's. - In this case, we need to stop the array index from incrementing if the current - truth is the user's, as they cannot have voted for themselves, leading to one - less votes than there are truths. - #} - {%- if truth.author_id == user.id -%} - {%- set_global index_delta = 2 -%} - {% endif %} - {% set truth_index = truth.number - index_delta %} - - {% if user.is_admin == true %} - {% include "editable_truth" %} - {% else %} - {% include "truth" %} - {% endif %} - {% endfor %} - - {% if user.logged_in == true and user.is_admin == false %} -
- -
- {% endif %} - - {# If admin, show an additional box for creating a new Truth. #} - {% if user.is_admin == true %} -
-

Nouvelle vérité

-
- {% include "truth_editor" %} -
-
- {% endif %} -
- -
-
- -
-
-
- - - diff --git a/templates/tags/filtered_truths.html.tera b/templates/tags/filtered_truths.html.tera new file mode 100644 index 0000000..cb94d54 --- /dev/null +++ b/templates/tags/filtered_truths.html.tera @@ -0,0 +1,28 @@ +{% extends "base_page" %} + +{% block top_bar_side %} +{% endblock %} + +{% block body %} + {% if tags | length == 0 %} +

Ensemble des vérités à ce jour

+ {% elif tags | length == 1 %} +

Vérités correponsdantes au thème

+ {% else %} +

Vérités correponsdantes aux thèmes

+ {% endif %} + +
+ {% for tag in tags %} + {{ tag.name }} + {% endfor %} +
+ + {% for truth in truths %} +
+

Semaine {{ truth.week }} - Vérité {{ truth.number }}

+

{{ truth.rendered_text | safe }}

+
+ {% endfor %} +{% endblock %} diff --git a/templates/tags/index.html.tera b/templates/tags/index.html.tera new file mode 100644 index 0000000..5e423d4 --- /dev/null +++ b/templates/tags/index.html.tera @@ -0,0 +1,42 @@ +{% extends "base_page" %} + +{% block top_bar_side %} +{% endblock %} + +{% block body %} +

Liste des thèmes de Vérité

+ +
+ {% for tag in tags %} +
+ +
+ {% endfor %} +
+ + + {% if user.is_admin %} +
+ +

Éditer les thèmes

+ +
+ {% for tag in tags %} +
+ + + + +
+ {% endfor %} +
+ +
+
+ + +
+ +
+ {% endif %} +{% endblock %} diff --git a/templates/truth.html.tera b/templates/truth.html.tera deleted file mode 100644 index c54388b..0000000 --- a/templates/truth.html.tera +++ /dev/null @@ -1,26 +0,0 @@ -
-

Vérité {{ truth.number }}

-

{{ truth.rendered_text | safe }}

- {% if user.logged_in %} -
- - {% endif %} -
diff --git a/templates/weeks/editable_truth.tera b/templates/weeks/editable_truth.tera new file mode 100644 index 0000000..8d75882 --- /dev/null +++ b/templates/weeks/editable_truth.tera @@ -0,0 +1,32 @@ +
+

Vérité {{ truth.number }}

+

{{ truth.rendered_text | safe }}

+
+ {% for tag in truth.tags %} +
+ +
+ + {{ tag.name }} + + {% endfor %} +
+
+
+ {% include "weeks/truth_editor" %} +
+ {% if tags | length > 0 and tags | length > truth.tags | length %} +
+ + +
+ {% endif %} +
diff --git a/templates/weeks/index.html.tera b/templates/weeks/index.html.tera new file mode 100644 index 0000000..b3527d5 --- /dev/null +++ b/templates/weeks/index.html.tera @@ -0,0 +1,120 @@ +{% extends "base_page" %} + +{% block scripts %} + {% if user.logged_in == true and not user.is_admin %} + + {% endif %} + + +{% endblock %} + +{#{% import "week_change_arrows" as week_macro %}#} +{# For some reason the import does not work ? Figure it out at some point... #} +{%- macro display(display_character, to, enabled) -%} + {%- set class = "week_change" -%} + {%- if enabled == true %} + {% set target = ("href=/" ~ to) %} + {%- else -%} + {% set class = class ~ " week_change_hidden" -%} + {% set target = "" %} + {%- endif -%} + {%- if enabled == true -%}{{- display_character -}}{%- endif -%} +{%- endmacro display -%} + +{# Remove the form if all votes are locked, to reduce confusion. #} +{% set lock_truth_form = vote_data.votes | length + 1 == truths | length and week_data.is_last_week != true %} + +{% block body %} + + {# Apparently needs to be inside the block ? #} + {% set back_arrow_enabled = week_data.number > 1 %} + {% set next_arrow_enabled = (week_data.is_last_week != true or user.is_admin == true) %} + {% set next_arrow_href = (week_data.number + 1) %} + {% if user.is_admin == true %} + {% set next_arrow_href = next_arrow_href ~ "/create" %} + {% set next_arrow_chara = '⥅' %} + {% else %} + {% set next_arrow_chara = '⟹' %} + {% endif %} + +

{{- self::display(display_character='⟸', to=(week_data.number - 1), enabled=back_arrow_enabled) }} + Semaine {{ week_data.number }} + {{- self::display(display_character=next_arrow_chara, to=next_arrow_href, enabled=next_arrow_enabled) -}}

+
+
+ {% if user.is_admin == true and week_data.is_last_week != true %} +
+ +
+ {% endif %} +
+ {{ week_data.rendered_text | safe }} + {%- if user.is_admin == true -%} +
+
+ + +
+ {% endif %} +
+ {% if user.logged_in == true and user.is_admin == false and not lock_truth_form %} +
+ {% endif %} + + {# Truths start at 1 but the array starts at 0 #} + {% set index_delta = 1 %} + {% for truth in truths %} + {# + The truths are in an ordered array, but one of them might be the user's. + In this case, we need to stop the array index from incrementing if the current + truth is the user's, as they cannot have voted for themselves, leading to one + less votes than there are truths. + #} + {%- if truth.author_id == user.id -%} + {%- set_global index_delta = 2 -%} + {% endif %} + {% set truth_index = truth.number - index_delta %} + + {% if user.is_admin == true %} + {% include "weeks/editable_truth" %} + {% else %} + {% include "weeks/truth" %} + {% endif %} + {% endfor %} + + {% if user.logged_in == true and user.is_admin == false and not lock_truth_form %} +
+ +
+ {% endif %} + + {# If admin, show an additional box for creating a new Truth. #} + {% if user.is_admin == true %} +
+

Nouvelle vérité

+
+ {% include "weeks/truth_editor" %} +
+
+ {% endif %} +
+ +
+
+ +
+
+
+{% endblock %} diff --git a/templates/weeks/truth.html.tera b/templates/weeks/truth.html.tera new file mode 100644 index 0000000..5eaa580 --- /dev/null +++ b/templates/weeks/truth.html.tera @@ -0,0 +1,40 @@ +{%- set is_disabled = "" -%} +{# If we are not during the active week, prevent changing vote but not sending a missing one. #} +{%- if week_data.is_last_week != true and vote_data.votes | filter(attribute="truth_id", value=truth.id) -%} + {%- set is_disabled = "disabled" -%} +{%- endif -%} + +
+

Vérité {{ truth.number }}

+

{{ truth.rendered_text | safe }}

+
+
+ {% for tag in truth.tags %} + {{ tag.name }} + {% endfor %} +
+ {% if user.logged_in %} +
+ + {% endif %} +
diff --git a/templates/truth_editor.html.tera b/templates/weeks/truth_editor.html.tera similarity index 93% rename from templates/truth_editor.html.tera rename to templates/weeks/truth_editor.html.tera index 894dac4..615ed3e 100644 --- a/templates/truth_editor.html.tera +++ b/templates/weeks/truth_editor.html.tera @@ -1,4 +1,4 @@ -