diff --git a/Cargo.lock b/Cargo.lock index 65b8c9d..58f9b72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -578,7 +578,7 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "fabula_votes_server" -version = "1.3.0" +version = "1.4.0" dependencies = [ "argon2", "blake2", diff --git a/Cargo.toml b/Cargo.toml index d20f8c4..ec70a8c 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.3.0" +version = "1.4.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 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/database_records.rs b/src/database_records.rs index 001df45..2f49061 100644 --- a/src/database_records.rs +++ b/src/database_records.rs @@ -19,22 +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)] @@ -68,3 +72,11 @@ pub struct Week { 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 b6e53ff..bf2299c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use rocket_dyn_templates::Template; use rocket_db_pools::Connection; mod auth; - +mod tag; mod truth; mod vote; mod week; @@ -33,6 +33,7 @@ fn rocket() -> _ { .attach(week::stage()) .attach(truth::stage()) .attach(vote::stage()) + .attach(tag::stage()) .mount("/", routes![index]) .attach(database::stage()) .attach(Template::fairing()) 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/week.rs b/src/week.rs index 6148f08..79608b7 100644 --- a/src/week.rs +++ b/src/week.rs @@ -10,7 +10,8 @@ use sqlx::{Acquire, Executor}; use crate::{auth, vote}; use crate::auth::User; use crate::database::Db; -use crate::database_records::{DisplayTruth, Player, Truth, Week}; +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 { @@ -57,7 +58,7 @@ pub async fn week(week_number: u8, mut db: Connection, cookies: &CookieJar<' // 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") + 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, @@ -67,15 +68,22 @@ pub async fn week(week_number: u8, mut db: Connection, cookies: &CookieJar<' } }; + 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 + vote_data: vote_data, + tags: tags }) } else { - let truths: Vec = match sqlx::query_as("SELECT id, week, number, author_id, rendered_text FROM Truths WHERE week == $1 ORDER BY number") + 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, @@ -85,6 +93,10 @@ pub async fn week(week_number: u8, mut db: Connection, cookies: &CookieJar<' } }; + 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, diff --git a/static_files/style.css b/static_files/style.css index 5c108dc..0721675 100644 --- a/static_files/style.css +++ b/static_files/style.css @@ -74,14 +74,18 @@ } .week_change:link, .week_change:visited, -.individual_truth > a, .individual_truth > a:link, .individual_truth > a: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 { +.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; } @@ -101,6 +105,76 @@ } } +.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; @@ -147,3 +221,10 @@ body { body > h2 { padding-left: 0.25eM; } + +nav { + height: fit-content; + margin: 0 0.25em; + border-left: 0.15em solid slategray; + border-right: 0.15em solid slategray; +} diff --git a/templates/base_page.html.tera b/templates/base_page.html.tera index f56242b..c598fa8 100644 --- a/templates/base_page.html.tera +++ b/templates/base_page.html.tera @@ -13,19 +13,33 @@ {% block top_bar %}
-

{{ title }}

- {% if user.logged_in == true %} - - {% else %} - - {% endif %} +
+

{{ title }}

+ +
+
+ {% block top_bar_side %} +
+ {% if user.logged_in == true %} + + {% else %} + + {% endif %} +
+ {% endblock %} +
{% endblock %} diff --git a/templates/tags/filtered_truths.html.tera b/templates/tags/filtered_truths.html.tera new file mode 100644 index 0000000..ad2ffb9 --- /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/weeks/editable_truth.tera b/templates/weeks/editable_truth.tera index 501302a..3c2c968 100644 --- a/templates/weeks/editable_truth.tera +++ b/templates/weeks/editable_truth.tera @@ -1,8 +1,30 @@

Vérité {{ truth.number }}

{{ truth.rendered_text | safe }}

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

-
+ {% include "weeks/truth_editor" %}
+
+ + +
diff --git a/templates/weeks/truth.html.tera b/templates/weeks/truth.html.tera index ca52998..5a420e6 100644 --- a/templates/weeks/truth.html.tera +++ b/templates/weeks/truth.html.tera @@ -7,6 +7,12 @@

Vérité {{ truth.number }}

{{ truth.rendered_text | safe }}

+
+
+ {% for tag in truth.tags %} + {{ tag.name }} + {% endfor %} +
{% if user.logged_in %}