tag: Introduce tag system

Create a new table representing tags, composed of a name and color.
They can be used to tag truths and filter them later.
Tags display under the truths they correspond to and can be clicked
to access all truths matching this tag.

Introduce a new element in the top bar to allow navigating to the
tag list, which can be used to create and edit tags for the admin
and used to select a list of tags to filter against for everyone.

Update the database records of the truths to include the tag vector.
As the database query result is a multi-row result, it cannot be
parsed automatically so it needs to be skipped and retrieved
manually.
This commit is contained in:
trotFunky 2024-08-01 21:18:30 +01:00
parent d79375365d
commit 19898e1b09
13 changed files with 551 additions and 25 deletions

2
Cargo.lock generated
View file

@ -578,7 +578,7 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]]
name = "fabula_votes_server"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"argon2",
"blake2",

View file

@ -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

View file

@ -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
);

View file

@ -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<Tag>,
}
#[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<Tag>,
}
#[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
}

View file

@ -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())

295
src/tag.rs Normal file
View file

@ -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<Db>, cookies: &CookieJar<'_>) -> Vec<Tag> {
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::<Tag>::new()
}
}
}
pub async fn get_truth_tags(truth_id: u32, db: &mut Connection<Db>) -> Vec<Tag> {
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::<Tag>::new()
}
}
}
#[get("/")]
pub async fn tag_index(mut db: Connection<Db>, cookies: &CookieJar<'_>) -> Template {
let user = auth::get_user(&mut db, cookies).await;
let tags: Vec<Tag> = get_all_tags(&mut db, cookies).await;
Template::render("tags/index", context!{
user: user,
tags: tags
})
}
#[get("/filter?<tags>")]
pub async fn filtered_by_tags(tags: Vec<String>, mut db: Connection<Db>) -> Template {
let last_week = week::get_last_week(&mut db).await;
let filtered_truths: Vec<DisplayTruth> = 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::<DisplayTruth>::new()
}
}
} else {
let mut query_builder: QueryBuilder<Sqlite> = 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::<DisplayTruth>().fetch_all(&mut **db).await {
Ok(truths) => {
debug!("Got filtered truths by tags");
truths
},
Err(error) => {
error!("Error while fetching filtered truths : {error}");
Vec::<DisplayTruth>::new()
}
}
};
let filter_tags: Vec::<Tag> = 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::<Tag>::new()
}
};
Template::render("tags/filtered_truths", context!{
tags: filter_tags,
truths: filtered_truths
})
}
#[post("/create", data="<new_tag>")]
pub async fn create_tag(new_tag: Form<Tag>, mut db: Connection<Db>, 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/<tag_name>")]
pub async fn delete_tag(tag_name: &str, mut db: Connection<Db>, 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/<tag_name>", data="<updated_tag>")]
pub async fn update_tag(tag_name: &str, updated_tag: Form<Tag>, mut db: Connection<Db>, 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("/<week>/tag/<truth_id>", data="<tag_form>")]
pub async fn tag_truth(week: u8, truth_id: u32, tag_form: Form<TagForm>, mut db: Connection<Db>, 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("/<week>/untag/<truth_id>", data="<tag_form>")]
pub async fn untag_truth(week: u8, truth_id: u32, tag_form: Form<TagForm>, mut db: Connection<Db>, 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])
})
}

View file

@ -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<Db>) -> u8 {
@ -57,7 +58,7 @@ pub async fn week(week_number: u8, mut db: Connection<Db>, cookies: &CookieJar<'
// FIXME : This is fucking *trash* but fucking hell mate
if user.is_admin {
let truths: Vec<Truth> = match sqlx::query_as("SELECT * FROM Truths WHERE week == $1 ORDER BY number")
let mut truths: Vec<Truth> = 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<Db>, cookies: &CookieJar<'
}
};
for truth in &mut truths {
truth.tags = tag::get_truth_tags(truth.id, &mut db).await;
}
let tags: Vec<Tag> = 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<DisplayTruth> = match sqlx::query_as("SELECT id, week, number, author_id, rendered_text FROM Truths WHERE week == $1 ORDER BY number")
let mut truths: Vec<DisplayTruth> = 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<Db>, 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,

View file

@ -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;
}

View file

@ -13,7 +13,18 @@
<body>
{% block top_bar %}
<div class="top_bar">
<h1>{{ title }}</h1>
<div class="top_bar_side">
<h1><a href="/">{{ title }}</a></h1>
<nav>
Aller à :
<a href="/">Vérités</a>
-
<a href="/tags">Thèmes</a>
</nav>
</div>
<div class="top_bar_side">
{% block top_bar_side %}
<div>
{% if user.logged_in == true %}
<form class="login" id="logout" action="/{{ week_data.number }}/logout" method="POST">
Connecté en tant que <b>{{ user.name }}</b>
@ -26,6 +37,9 @@
<button form="login">Se connecter</button>
</form>
{% endif %}
</div>
{% endblock %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends "base_page" %}
{% block top_bar_side %}
{% endblock %}
{% block body %}
{% if tags | length == 0 %}
<h2>Ensemble des vérités à ce jour</h2>
{% elif tags | length == 1 %}
<h2>Vérités correponsdantes au thème</h2>
{% else %}
<h2>Vérités correponsdantes aux thèmes</h2>
{% endif %}
<div class="truth_tags">
{% for tag in tags %}
<a class="tag" href="/tags/filter?tags={{ tag.name }}"
style="background-color: {{ tag.color }}">{{ tag.name }}</a>
{% endfor %}
</div>
{% for truth in truths %}
<div class="individual_truth">
<a href="/{{ truth.week }}/#{{ truth.number }}"><h3 id="{{ truth.number }}">Semaine {{ truth.week }} - Vérité {{ truth.number }}</h3></a>
<p>{{ truth.rendered_text | safe }}</p>
</div>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,42 @@
{% extends "base_page" %}
{% block top_bar_side %}
{% endblock %}
{% block body %}
<h2>Liste des thèmes de Vérité</h2>
<form class="tag_list" id="filter_by_tags" action="/tags/filter">
{% for tag in tags %}
<div class="tag" style="background-color: {{ tag.color }}">
<label><input type="checkbox" form="filter_by_tags" name="tags" value="{{ tag.name }}">{{ tag.name }}</label>
</div>
{% endfor %}
</form>
<button form="filter_by_tags">Voir les vérités correspondantes</button>
{% if user.is_admin %}
<hr>
<h2>Éditer les thèmes</h2>
<div class="tag_list">
{% for tag in tags %}
<form class="tag" style="background-color: {{ tag.color }}" id="edit_tag[{{ tag.name }}]" method="post" action="/tags/update/{{ tag.name }}">
<input type="text" form="edit_tag[{{ tag.name }}]" name="name" value="{{ tag.name }}">
<input type="color" form="edit_tag[{{ tag.name }}]" name="color" value="{{ tag.color }}">
<button>Mettre à jour</button>
<button formaction="/tags/delete/{{ tag.name }}">Supprimer</button>
</form>
{% endfor %}
</div>
<form class="new_tag" id="new_tag" action="/tags/create" method="post">
<div>
<label>Nom de l'étiquette: <input type="text" form="new_tag" name="name"/></label>
<label>Couleur: <input type="color" form="new_tag" name="color"></label>
</div>
<button>Ajouter une étiquette</button>
</form>
{% endif %}
{% endblock %}

View file

@ -1,8 +1,30 @@
<div class="individual_truth">
<h3>Vérité {{ truth.number }}</h3>
<p>{{ truth.rendered_text | safe }}</p>
<div class="truth_tags">
{% for tag in truth.tags %}
<form action="/{{ week_data.number }}/untag/{{ truth.id }}" method="post">
<button name="name" value="{{ tag.name }}">❌</button>
</form>
<a class="tag" href="/tags/filter?tags={{ tag.name }}" style="background-color: {{ tag.color }}">
{{ tag.name }}
</a>
{% endfor %}
</div>
<hr/>
<form action="/{{ week_data.number }}/edit/{{ truth.number }}" method="POST">
<form class="truth_edit_form" action="/{{ week_data.number }}/edit/{{ truth.number }}" method="POST">
{% include "weeks/truth_editor" %}
</form>
<form class="truth_edit_form" action="/{{ week_data.number }}/tag/{{ truth.id }}" method="post">
<label>Thème supplémentaire:
<select name="name">
{% for tag in tags %}
{% if not truth.tags is containing(tag) %}
<option value="{{ tag.name }}" style="background-color: {{ tag.color }}">{{ tag.name }}</option>
{% endif %}
{% endfor %}
</select>
</label>
<button>Ajouter un thème</button>
</form>
</div>

View file

@ -7,6 +7,12 @@
<div class="individual_truth">
<a href="/{{ truth.week }}/#{{ truth.number }}"><h3 id="{{ truth.number }}">Vérité {{ truth.number }}</h3></a>
<p>{{ truth.rendered_text | safe }}</p>
<hr>
<div class="truth_tags">
{% for tag in truth.tags %}
<a class="tag" href="/tags/filter?tags={{ tag.name }}" style="background-color: {{ tag.color }}">{{ tag.name }}</a>
{% endfor %}
</div>
{% if user.logged_in %}
<hr/>
<label>