Compare commits

..

39 commits
v1.1.1 ... main

Author SHA1 Message Date
76de321d41 templates: Don't show the tagging form if no tags
If there are no tags but the form is still shown, the admin
could ask to add an empty tag returning an error 422
unprocessable entity.
Remove if there are no tags available
2024-08-01 22:55:44 +01:00
4d5a423c78 template: Add anchor link to the editable truths
For some reason I did not add the anchor links to the editable truths
for the admin users.
Add them so that the admin can benefit as well.
2024-08-01 22:45:40 +01:00
322d37ce48 templates: Remove the extra / in the anchor link
Anchor links do not need an additional / to work : the # can be added directly
at the end of the previous URL.
Remove it from the existing anchor tags in the truths.
2024-08-01 22:43:25 +01:00
24ab2fc5f2 vote_chart: Remove hash part of the URL
Now that the truths have anchor links, a user could select one and send the link
directly to the truth, or refresh the page to there.
However, the local part of the URL would be used by the script to fetch the voting
data, which fail as it isn't expected by the server and is, anyway, malformed.

Remove the local hash from the URL before requesting the voting data.
2024-08-01 22:42:07 +01:00
19898e1b09 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.
2024-08-01 21:55:47 +01:00
d79375365d style: Update top bar
Style the topbar to make it more useful in the future.
Style the login element to make the form vertical and
properly aligned, in order for it to fit well on both
desktop and mobile.

Small adjustment to the truth editor style to space it
out a little bit.
2024-08-01 20:23:18 +01:00
fb115e1bba README: Add forgotten frontend dependency
I only added the Rust dependencies to the README, but we do have one
for the Javascript run in the frontend.

Add Chart.js to the README.
2024-07-30 23:34:17 +01:00
198be70a99 week: Implement get_last_week
Previously, we only needed to get the last week number from the index, to
properly redirect to it.
However, we will need it in the future in other places.

Implement a function that centralizes this database operation.
2024-07-30 23:25:41 +01:00
f7efe0b66c style: Unify link behaviors, pad truth body
Pad the truths bodies a little bit inwards, to give more space.
Unify the link styling in one big messy pile of selectors, rather than
in multiple places.
2024-07-30 23:09:55 +01:00
072889173f week: Add week number to the DisplayTruth
In order to have all the relevant information for displaying a truth everywhere, add
its week number.
Fetch it during the week rendering and use it for the link back.
2024-07-29 22:54:13 +01:00
4e1e9facc6 tempaltes/truth: Add an anchor and link to each truth
Users might want to share specific truths, or it might be useful to link to them in the future.
Set the id of the h3 element for each truth to its number in the week and add an anchor link to
the full h3 element, to be able to click anywhere.

Update the stylesheet to hide the link color and decorations, add animation an hover color as
with the week navigation arrows.
2024-07-29 22:34:43 +01:00
1b4a934398 auth: Split off vote data from the user
Now that the application is going to have multiple pages, vote data makes no sense
to keep with the user.
The user struct will be used everywhere to check for authentication, which is not
the case for previous votes.

Create a new struct and function in src/vote.rs to retrieve existing votes and
use them in places where user.votes was used previously.
Remove vote-related code from src/auth.rs and the week number dependence that it
required.
2024-07-28 16:55:22 +01:00
1419bb6d51 tempates/base: Change language, update title
Now that we have the name of the world, we can show it in the title.

The base I used supposed an english language page, which is incorrect here.
Set language as French.
2024-07-28 16:55:22 +01:00
b1f37a4e4a templates: Make the index a class
The index is a full HTML page with some boilerplate that would be needed in all
pages deviating from the truth list, specifically the head and general structure.

Make the core structure a base template, rebuild the week index page inheriting
from it.
Change which template is used by the route accordingly.
2024-07-28 16:55:18 +01:00
afdac98dea templates: Move week-related includes to a folder
In preparation to adding a new page, move the week-related included files in a specific
sub-folder.
Change includes accordingly.
2024-07-27 22:41:06 +01:00
36b2e61e26 main: Serve static files from /static path
Static files served with FileServer have a low priority : all other possible routes
a this root will be checked first, before they get returned.
As we have a lot of routes at the root, they will be hit a lot before we get
to the static files.

Move the static files to a dedicated path to reduce those internal redirections,
update the paths in the HTML and add one for the favicon so it continues to work.
2024-07-27 21:47:26 +01:00
222acbb4f8 Rocket: Use fairings for routes of sub-files
Instead of adding all routes manually in the Launch function for Rocket,
implement fairings for all the different files that adds the routing to
the rocket in a self-contained manner.
2024-07-27 21:22:34 +01:00
120472354c auth: Remove auth bypass function
The authentication bypass function was mostly used for testing, before the
full flow was implemented.
Remove it now that it is no longer useful.
2024-07-27 21:08:24 +01:00
0c162d3b42 vote: Always display graph for the admin 2024-07-26 23:49:23 +01:00
c4e252dbc2 auth: Add a logout button
Add a logout button that clears the auth cookies, logging out the user.
It also tries to remove the auth token from the databse, but will ignore
any error during the database operation.

Do some include clean-ups as well.
2024-07-26 20:32:16 +01:00
36be6b51ae style: Update truth titles, change style of hr
Center the truth titles and add a border with the body, padding accordingly.
To no overload the page and maintain connection, make the hr dotted and match the color.
2024-07-26 19:52:02 +01:00
07d8cf42d7 vote: Properly take player and truth numbers into account
Fetch the amount of truths for the week and the player count to find the total possible votes.
Use this to remove the hardocded value in the check.
Update the check to fail if there was an error as well.

Now that the truth count is calculated, send it to the javascript to build the graph
labels automatically as well.
2024-07-26 19:25:41 +01:00
ba98c3be84 index: Remove the form if all votes were cast
Currently, if all votes are cast and the week is locked the selections are disabled but
the form and button still exist and might cause confusion.
Remove them if all votes are cast and we are not on the last week anymore.
2024-07-26 17:57:06 +01:00
4e1b13dc85 vote: Fix logic to hide graph if week is active
The goal is to not display the graph until the active week has been changed, but the logic
retuned early if it wasn't.

Simplify the query and check directly if the current week is the last one, if so do not
show the graph.
2024-07-26 11:30:12 +01:00
5ec38ee44f v1.2.0: Update production version
Main changes are the week handling, proper redirections, a bunch of fixes
and small adjustments, not displaying the graph until all votes and the
week changed.
2024-07-26 01:37:17 +01:00
a0b79a17ea Redirects: properly redirect to the current week
Previously, most redirects targeted the root of the application.
This was okay for the first part of development where only one week would be
active, but would be annoying when using multiple weeks.

Change those redirects to call week::week.

Change the login path to be dependant on the current week as well,
so it can be correctly redirected.
2024-07-26 01:08:47 +01:00
e08a46af3a week: Create new weeks and change active one
Implement a way for admins to introduce new weeks and set them as the last active one.
The last active week is the last one that is accessible to players, and will also prevent
the graph from being displayed.

Implement arrows for navigating between the weeks in the HTML.
2024-07-26 00:52:41 +01:00
635716c04b vote: Change graph display condition, don't fetch for admin
Now, the graph will only show when everyone has voted and the week is no longer
the last one active.
This is to minimize information gathering by looking during voting.

Don't fetch the vote data for the admin : it is not used, so no need to
query the database.

Update README with new TODOs and clean up main includes a bit.
2024-07-26 00:47:10 +01:00
f41f5142c9 styles: Minor reorganization
Properly split what is custom classes and regular HTML tags.
2024-07-26 00:41:05 +01:00
dfdd079ac4 templates/truth: Prevent changing vote if not last week
If the week is not active anymore, prevent changing any vote but allow adding missing ones.
2024-07-25 23:44:00 +01:00
207ce6c1d2 templates: Check vote against truth id, not number
I made a choice to only log confirmed votes : there is no blank vote in the database.
This means that when fetching a user's vote, if they have not voted for everyone
there will be votes missing.
As this is sent to the templating engine via a Vector, the ordering of the votes
will be incorrect : all existing votes will follow each other, and there will be
missing votes at the end.

Update the select logic in the truth template to account for that by checking
the truth_id directly, rather than via the index of the array. (O(N²)...)
Remove 'has_vote' as this is not useful anymore.
2024-07-25 23:30:02 +01:00
2b3dd28fed style: Fix edit box clipping the graph
It looks like the width of the element is calculated *inside* the padding, so the
outer width is really width+2*padding.
This leads to the edit box going outside of the truth column and clipping the graph.

Compute the width to take the padding into account.
2024-07-25 18:39:07 +01:00
f7e1218f21 truths: Change truth creation URL
As we will be able to create a new week, <week>/new to <week>/new_truth to
differentiate it from the week creation URL.
2024-07-25 16:54:19 +01:00
f74ed20e80 vote_chart: Add custom colors
I didn't like the default colors, so introduce a new set of them and
set them to the datasets.
Do it after the sort, otherwise the colors wouldn't stay consistent.
2024-07-24 18:11:37 +01:00
4c89a0783d vote_chart: Add title to the graph 2024-07-24 18:08:13 +01:00
d0843d600e vote_chart: Control the graph ratio switch
The media query used to switch the ratio of the graph would only fire when
switching from landscape to portrait, or the other way around.
This makes controlling the point where the graph switches to vertical is
only possible at this exact point.

Introduce a limit aspect-ratio that controls the change and use it for the
MediaQuery as well.

Make sure we don't delete/recreate the chart for no reason as well.
2024-07-24 16:56:08 +01:00
8fd8ce8220 vote_chart: Remove test code
This was written for testing the chart before plugging in to the database,
remove it now that is not useful anymore.
2024-07-24 16:27:43 +01:00
982a7ffd65 vote_chart: sort parsed votes
The SQL query retrieving the votes is deterministicly sorted, but goes through
a HashMap, for ease of processing and transfer, which does not maintaing order.

Sort the resulting object in the Javascript to keep a consistent order in the graph.
2024-07-24 15:23:52 +01:00
8547312a78 README: Update todo, fix typo 2024-07-23 22:14:18 +01:00
23 changed files with 1116 additions and 278 deletions

2
Cargo.lock generated
View file

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

View file

@ -3,7 +3,7 @@ name = "fabula_votes_server"
license = "MPL-2.0" license = "MPL-2.0"
readme = "README.md" readme = "README.md"
authors = ["trotFunky"] authors = ["trotFunky"]
version = "1.1.1" version = "1.4.1"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View file

@ -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" ! 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 - [x] Being able to change from one week to the next
- [ ] Create new weeks for the admin - [x] Create new weeks for the admin
- [ ] Proper week redirection - [x] Proper week redirection
- [ ] Add introduction to the weekly truths - [ ] Correctly handle non-existing week number
- [x] Add introduction to the weekly truths
- [ ] Bundle static assets in the binary - [ ] 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 - [ ] Cache those results
- [ ] Centralize Markdown parsing ? - [ ] 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 # 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 - [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) - [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 - [Tera](https://docs.rs/tera/latest/tera/), for templating
- [Argon2](https://docs.rs/argon2/latest/argon2/), for password hashing - [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 - [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 # License
The code present in this repository is licensed under the Mozilla Public License 2.0. The code present in this repository is licensed under the Mozilla Public License 2.0.

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

@ -8,8 +8,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
use argon2::{Argon2, PasswordHash, PasswordVerifier}; use argon2::{Argon2, PasswordHash, PasswordVerifier};
use blake2::{Blake2b512, Digest}; use blake2::{Blake2b512, Digest};
use blake2::digest::FixedOutput; use blake2::digest::FixedOutput;
use crate::database_records::{AuthTokens, PlayerLoginInfo, Vote}; use rocket::fairing::AdHoc;
use crate::database; use crate::database_records::{AuthTokens, PlayerLoginInfo};
use crate::{database, week};
use database::Db; use database::Db;
// TODO: Make FromRequest guard https://api.rocket.rs/v0.5/rocket/request/trait.FromRequest and split admin // TODO: Make FromRequest guard https://api.rocket.rs/v0.5/rocket/request/trait.FromRequest and split admin
@ -20,11 +21,9 @@ pub struct User {
pub is_admin: bool, pub is_admin: bool,
pub id: u16, pub id: u16,
pub name: String, pub name: String,
pub has_week_vote: bool,
pub votes: Vec<Vote>
} }
pub async fn get_user(week: u8, db: &mut Connection<Db>, cookies: &CookieJar<'_>) -> User { pub async fn get_user(db: &mut Connection<Db>, cookies: &CookieJar<'_>) -> User {
let auth_token: Option<String> = match cookies.get_private("auth_token") { let auth_token: Option<String> = match cookies.get_private("auth_token") {
Some(cookie) => Some(cookie.value().to_string()), Some(cookie) => Some(cookie.value().to_string()),
None => None None => None
@ -86,26 +85,12 @@ pub async fn get_user(week: u8, db: &mut Connection<Db>, cookies: &CookieJar<'_>
(String::new(), false) (String::new(), false)
}; };
let votes: Vec<Vote> = 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::<Vote>::new()
})
} else {
Vec::<Vote>::new()
};
if logged_in { if logged_in {
User { User {
logged_in, logged_in,
is_admin, is_admin,
id: id_str.parse::<u16>().unwrap(), id: id_str.parse::<u16>().unwrap(),
name, name,
has_week_vote: if votes.is_empty() { false } else { true },
votes
} }
} else { } else {
User { User {
@ -113,8 +98,6 @@ pub async fn get_user(week: u8, db: &mut Connection<Db>, cookies: &CookieJar<'_>
is_admin: false, is_admin: false,
id: 0, id: 0,
name, name,
has_week_vote: false,
votes
} }
} }
} }
@ -125,8 +108,8 @@ pub struct AuthForm {
password: String password: String
} }
#[post("/login", data="<form>")] #[post("/<week>/login", data="<form>")]
pub async fn login(form: Form<AuthForm>, mut db: Connection<Db>, cookies: &CookieJar<'_>) -> Redirect { pub async fn login(week: u8, form: Form<AuthForm>, mut db: Connection<Db>, cookies: &CookieJar<'_>) -> Redirect {
let user_search: Result<PlayerLoginInfo, _> = sqlx::query_as("SELECT id, is_admin, name, pwd_hash FROM Players WHERE name == $1") let user_search: Result<PlayerLoginInfo, _> = sqlx::query_as("SELECT id, is_admin, name, pwd_hash FROM Players WHERE name == $1")
.bind(&form.name) .bind(&form.name)
.fetch_one(&mut **db) .fetch_one(&mut **db)
@ -135,7 +118,7 @@ pub async fn login(form: Form<AuthForm>, mut db: Connection<Db>, cookies: &Cooki
if user_search.is_err() { if user_search.is_err() {
error!("Login failed : invalid user {:?}, err: {:?}", form.name, user_search.err()); error!("Login failed : invalid user {:?}, err: {:?}", form.name, user_search.err());
cookies.add(("toast_error", "Impossible de se connecter !")); 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(); let new_user = user_search.unwrap();
@ -143,7 +126,7 @@ pub async fn login(form: Form<AuthForm>, mut db: Connection<Db>, cookies: &Cooki
if password_hash_parse.is_err() { if password_hash_parse.is_err() {
error!("Login failed : could not parse password hash {:?}", password_hash_parse.err()); error!("Login failed : could not parse password hash {:?}", password_hash_parse.err());
cookies.add(("toast_error", "Impossible de se connecter !")); 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(); let password_hash = password_hash_parse.unwrap();
@ -167,7 +150,7 @@ pub async fn login(form: Form<AuthForm>, mut db: Connection<Db>, cookies: &Cooki
Err(error) => { Err(error) => {
error!("Login failed : coult not store auth token in database : {error}"); error!("Login failed : coult not store auth token in database : {error}");
cookies.add(("toast_error", "Impossible de se connecter !")); cookies.add(("toast_error", "Impossible de se connecter !"));
return Redirect::to(uri!("/")); return Redirect::to(uri!(week::week(week)));
} }
} }
@ -177,24 +160,41 @@ pub async fn login(form: Form<AuthForm>, mut db: Connection<Db>, cookies: &Cooki
Err(err) => { Err(err) => {
error!("Login failed : invalid password for {:?}\nError : {err}", new_user.name); error!("Login failed : invalid password for {:?}\nError : {err}", new_user.name);
cookies.add(("toast_error", "Impossible de se connecter !")); 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<'_>) { #[post("/<week>/logout")]
if cookies.get_private("auth_token").is_some() { pub async fn logout(week: u8, mut db: Connection<Db>, cookies: &CookieJar<'_>) -> Redirect {
return let auth_token: Option<String> = match cookies.get_private("auth_token") {
} Some(cookie) => Some(cookie.value().to_string()),
let mut hasher = Blake2b512::new(); None => None
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()));
println!("Generated hash string : {hash_str}"); // 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])
})
} }

View file

@ -19,21 +19,26 @@ pub struct PlayerLoginInfo {
#[derive(sqlx::FromRow, Deserialize, Serialize)] #[derive(sqlx::FromRow, Deserialize, Serialize)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct Truth { pub struct Truth {
id: u32, pub id: u32,
week: u8, week: u8,
number: u8, number: u8,
author_id: u16, author_id: u16,
rendered_text: String, rendered_text: String,
raw_text: String, raw_text: String,
#[sqlx(skip)]
pub tags: Vec<Tag>,
} }
#[derive(sqlx::FromRow, Deserialize, Serialize)] #[derive(sqlx::FromRow, Deserialize, Serialize)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct DisplayTruth { pub struct DisplayTruth {
id: u32, pub id: u32,
week: u8,
number: u8, number: u8,
author_id: u16, author_id: u16,
rendered_text: String, rendered_text: String,
#[sqlx(skip)]
pub tags: Vec<Tag>,
} }
#[derive(sqlx::FromRow, Deserialize, Serialize)] #[derive(sqlx::FromRow, Deserialize, Serialize)]
@ -67,3 +72,11 @@ pub struct Week {
pub rendered_text: String, pub rendered_text: String,
pub raw_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

@ -1,17 +1,14 @@
#[macro_use] extern crate rocket; #[macro_use] extern crate rocket;
use rocket::{Rocket, Build, futures};
use rocket::fs::{FileServer, relative}; use rocket::fs::{FileServer, relative};
use rocket::http::CookieJar;
use rocket::response::Redirect; 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;
use sqlx::Error;
mod auth; mod auth;
mod tag;
mod truth; mod truth;
mod vote; mod vote;
mod week; mod week;
@ -20,19 +17,10 @@ mod week;
mod database; mod database;
mod database_records; mod database_records;
use database::Db; use database::Db;
use database_records::*;
use auth::User;
#[get("/")] #[get("/")]
async fn index(mut db: Connection<Db>) -> Redirect { async fn index(mut db: Connection<Db>) -> Redirect {
let current_week: u8 = match sqlx::query("SELECT number FROM Weeks WHERE is_last_week == 1;") let current_week: u8 = week::get_last_week(&mut db).await;
.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
}
};
Redirect::to(uri!(week::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})))
} }
@ -40,10 +28,13 @@ async fn index(mut db: Connection<Db>) -> Redirect {
#[launch] #[launch]
fn rocket() -> _ { fn rocket() -> _ {
rocket::build() rocket::build()
.mount("/", FileServer::from(relative!("static_files"))) .mount("/static/", FileServer::from(relative!("static_files")))
.mount("/", routes![index, vote::fetch_vote_data, vote::vote, truth::create_truth, truth::edit_truth, week::week, week::update_week, auth::login]) .attach(auth::stage())
.attach(week::stage())
.attach(truth::stage())
.attach(vote::stage())
.attach(tag::stage())
.mount("/", routes![index])
.attach(database::stage()) .attach(database::stage())
.attach(Template::fairing()) .attach(Template::fairing())
} }
// TODO: Random Row ID

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

@ -5,8 +5,9 @@ use rocket::response::Redirect;
use rocket_db_pools::{sqlx, Connection}; use rocket_db_pools::{sqlx, Connection};
use pulldown_cmark::{Parser, Options}; use pulldown_cmark::{Parser, Options};
use rocket::fairing::AdHoc;
use sqlx::Row; use sqlx::Row;
use crate::{auth, database}; use crate::{auth, database, week};
#[derive(FromForm)] #[derive(FromForm)]
pub struct TruthUpdateForm { pub struct TruthUpdateForm {
@ -17,10 +18,10 @@ pub struct TruthUpdateForm {
#[post("/<week>/edit/<truth_number>", data="<form>")] #[post("/<week>/edit/<truth_number>", data="<form>")]
pub async fn edit_truth(week: u8, truth_number: u8, form: Form<TruthUpdateForm>, pub async fn edit_truth(week: u8, truth_number: u8, form: Form<TruthUpdateForm>,
mut db: Connection<database::Db>, cookies: &CookieJar<'_>) -> Redirect { mut db: Connection<database::Db>, 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 { if !user.is_admin {
cookies.add(("toast_error", "Vous n'avez pas la permission de changer la vérité.")); 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(); let mut options = Options::empty();
@ -50,16 +51,16 @@ pub async fn edit_truth(week: u8, truth_number: u8, form: Form<TruthUpdateForm>,
} }
}; };
Redirect::to(uri!("/")) Redirect::to(uri!(week::week(week)))
} }
#[post("/<week>/new", data="<form>")] #[post("/<week>/new_truth", data="<form>")]
pub async fn create_truth(week: u8, form: Form<TruthUpdateForm>, pub async fn create_truth(week: u8, form: Form<TruthUpdateForm>,
mut db: Connection<database::Db>, cookies: &CookieJar<'_>) -> Redirect { mut db: Connection<database::Db>, 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 { if !user.is_admin {
cookies.add(("toast_error", "Vous n'avez pas la permission d'ajouter de vérité.")); 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;") 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<TruthUpdateForm>,
if truth_number == 0 { if truth_number == 0 {
error!("Error while getting max truth number."); error!("Error while getting max truth number.");
cookies.add(("toast_error", "Erreur lors de l'ajout de la vérité...")); 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(); let mut options = Options::empty();
@ -108,5 +109,11 @@ pub async fn create_truth(week: u8, form: Form<TruthUpdateForm>,
debug!("Truth was successfully added"); 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])
})
} }

View file

@ -9,9 +9,32 @@ use rocket::serde::json::Json;
use rocket_db_pools::{sqlx, Connection}; use rocket_db_pools::{sqlx, Connection};
use crate::{auth, database}; use crate::{auth, database, week};
use crate::database_records::{Vote, VotingData}; use crate::database_records::{Vote, VotingData};
#[derive(Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct WeeklyUserVotes {
pub has_week_vote: bool,
pub votes: Vec<Vote>
}
pub async fn get_weekly_user_votes(week: u8, user: &auth::User, db: &mut Connection<database::Db>) -> WeeklyUserVotes {
let votes: Vec<Vote> = 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::<Vote>::new()
})
} else {
Vec::<Vote>::new()
};
WeeklyUserVotes {has_week_vote: if votes.is_empty() { false } else { true }, votes}
}
#[derive(FromForm)] #[derive(FromForm)]
pub struct VoteForm { pub struct VoteForm {
truth_votes: HashMap<u32, u16> truth_votes: HashMap<u32, u16>
@ -20,11 +43,11 @@ pub struct VoteForm {
#[post("/<week>/vote", data="<form>")] #[post("/<week>/vote", data="<form>")]
pub async fn vote(week: u8, form: Form<VoteForm>, pub async fn vote(week: u8, form: Form<VoteForm>,
mut db: Connection<database::Db>, cookies: &CookieJar<'_>) -> Redirect { mut db: Connection<database::Db>, 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 { if !user.logged_in {
cookies.add(("toast_error", "Vous n'avez pas la permission de changer de vote.")); 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( let filtered_votes = form.truth_votes.iter().filter_map(
@ -37,9 +60,11 @@ pub async fn vote(week: u8, form: Form<VoteForm>,
} }
); );
let existing_votes = get_weekly_user_votes(week, &user, &mut db).await;
let mut had_error = false; let mut had_error = false;
for (truth_id, voted_id) in filtered_votes { 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) => { Some(vote) => {
if *voted_id == vote.voted_id { if *voted_id == vote.voted_id {
continue; continue;
@ -61,7 +86,7 @@ pub async fn vote(week: u8, form: Form<VoteForm>,
} }
None => { None => {
debug!("Player {:?} voting {voted_id} for truth {truth_id}", user.id); 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 // 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);") match sqlx::query("INSERT INTO Votes (truth_id, voter_id, voted_id) VALUES ($1, $2, $3);")
.bind(truth_id) .bind(truth_id)
@ -84,18 +109,20 @@ pub async fn vote(week: u8, form: Form<VoteForm>,
debug!("Vote successful") debug!("Vote successful")
} }
Redirect::to(uri!("/")) Redirect::to(uri!(week::week(week)))
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct VoteData { pub struct VoteData {
truth_count: u8,
votes: HashMap<String, Vec<u8>>, votes: HashMap<String, Vec<u8>>,
} }
// TODO: Cache vote count ? Maintain in state ? // TODO: Cache vote count ? Maintain in state ?
#[get("/<week>/votes", format = "application/json")] #[get("/<week>/votes", format = "application/json")]
pub async fn fetch_vote_data(week: u8, mut db: Connection<database::Db>) -> Option<Json<VoteData>> { pub async fn fetch_vote_data(week: u8, mut db: Connection<database::Db>, cookies: &CookieJar<'_>) -> Option<Json<VoteData>> {
let user = auth::get_user(&mut db, cookies).await;
let raw_votes: Vec<VotingData> = sqlx::query_as(" let raw_votes: Vec<VotingData> = sqlx::query_as("
SELECT Players.name as votes_for, Truths.number as truth_number, count(*) as votes FROM Votes SELECT Players.name as votes_for, Truths.number as truth_number, count(*) as votes FROM Votes
JOIN Players ON Votes.voted_id == Players.id JOIN Players ON Votes.voted_id == Players.id
@ -109,12 +136,31 @@ pub async fn fetch_vote_data(week: u8, mut db: Connection<database::Db>) -> Opti
Vec::<VotingData>::new() Vec::<VotingData>::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}); 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; 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::<String, Vec<u8>>::new(); let mut vote_data = HashMap::<String, Vec<u8>>::new();
let mut next_truth_number = 1; let mut next_truth_number = 1;
for raw_vote in raw_votes { for raw_vote in raw_votes {
@ -140,12 +186,11 @@ pub async fn fetch_vote_data(week: u8, mut db: Connection<database::Db>) -> Opti
next_truth_number = raw_vote.truth_number + 1; 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 { 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]) rocket.mount("/", routes![vote, fetch_vote_data])
}) })
} }

View file

@ -4,16 +4,30 @@ use rocket::form::Form;
use rocket::http::CookieJar; use rocket::http::CookieJar;
use rocket::response::Redirect; use rocket::response::Redirect;
use rocket_db_pools::{sqlx, Connection}; use rocket_db_pools::{sqlx, sqlx::Row, Connection};
use rocket_dyn_templates::{context, Template}; use rocket_dyn_templates::{context, Template};
use crate::auth; use sqlx::{Acquire, Executor};
use crate::{auth, vote};
use crate::auth::User; use crate::auth::User;
use crate::database::Db; 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 {
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("/<week_number>")] #[get("/<week_number>")]
pub async fn week(week_number: u8, mut db: Connection<Db>, cookies: &CookieJar<'_>) -> Template { pub async fn week(week_number: u8, mut db: Connection<Db>, cookies: &CookieJar<'_>) -> Template {
let user: User = auth::get_user(week_number, &mut db, cookies).await; let user: User = auth::get_user(&mut db, cookies).await;
let other_players = if user.logged_in { 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") match sqlx::query_as("SELECT id, name FROM Players WHERE id <> $1 AND is_admin == 0 ORDER BY name")
@ -21,7 +35,7 @@ pub async fn week(week_number: u8, mut db: Connection<Db>, cookies: &CookieJar<'
.fetch_all(&mut **db).await { .fetch_all(&mut **db).await {
Ok(v) => v, Ok(v) => v,
Err(error) => { Err(error) => {
println!("Some error while getting players : {error}"); error!("Some error while getting players : {error}");
Vec::<Player>::new() Vec::<Player>::new()
} }
} }
@ -40,9 +54,11 @@ pub async fn week(week_number: u8, mut db: Connection<Db>, cookies: &CookieJar<'
} }
}; };
let vote_data: WeeklyUserVotes = vote::get_weekly_user_votes(week_number,&user, &mut db).await;
// FIXME : This is fucking *trash* but fucking hell mate // FIXME : This is fucking *trash* but fucking hell mate
if user.is_admin { 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) .bind(week_number)
.fetch_all(&mut **db).await { .fetch_all(&mut **db).await {
Ok(v) => v, Ok(v) => v,
@ -52,14 +68,22 @@ pub async fn week(week_number: u8, mut db: Connection<Db>, cookies: &CookieJar<'
} }
}; };
Template::render("index", context! { 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, week_data: week_data,
truths: truths, truths: truths,
user: user, user: user,
other_players: other_players, other_players: other_players,
vote_data: vote_data,
tags: tags
}) })
} else { } else {
let truths: Vec<DisplayTruth> = match sqlx::query_as("SELECT id, 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) .bind(week_number)
.fetch_all(&mut **db).await { .fetch_all(&mut **db).await {
Ok(v) => v, Ok(v) => v,
@ -69,11 +93,16 @@ pub async fn week(week_number: u8, mut db: Connection<Db>, cookies: &CookieJar<'
} }
}; };
Template::render("index", context! { 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, week_data: week_data,
truths: truths, truths: truths,
user: user, user: user,
other_players: other_players, other_players: other_players,
vote_data: vote_data
}) })
} }
} }
@ -81,10 +110,10 @@ pub async fn week(week_number: u8, mut db: Connection<Db>, cookies: &CookieJar<'
#[post("/<week>/edit", data="<raw_intro>")] #[post("/<week>/edit", data="<raw_intro>")]
pub async fn update_week(week: u8, raw_intro: Form<String>, pub async fn update_week(week: u8, raw_intro: Form<String>,
mut db: Connection<Db>, cookies: &CookieJar<'_>) -> Redirect { mut db: Connection<Db>, 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 { if !user.is_admin {
cookies.add(("toast_error", "Vous n'avez pas la permission de changer la semaine.")); cookies.add(("toast_error", "Vous n'avez pas la permission de changer la semaine."));
return Redirect::to(uri!("/")); return Redirect::to(uri!(week(week)));
} }
let mut options = Options::empty(); let mut options = Options::empty();
@ -112,5 +141,106 @@ pub async fn update_week(week: u8, raw_intro: Form<String>,
} }
}; };
Redirect::to(uri!("/")) Redirect::to(uri!(week(week)))
}
#[post("/<week>/set_last")]
pub async fn set_last_week(week: u8, 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 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("/<week>/create")]
pub async fn create_week(week: u8, 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 changer la semaine."));
return Redirect::to(uri!(week(week - 1)));
}
let week_check: Result<Option<u8>, 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])
})
} }

View file

@ -2,10 +2,22 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center;
margin-right: 2em; 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; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
@ -23,14 +35,28 @@
padding-bottom: 2em; padding-bottom: 2em;
} }
.individual_truth h3 {
text-align: center;
padding-bottom: 4px;
border-bottom: slategray solid 0.15em;
}
.individual_truth p {
padding: 0 0.5em;
}
.editor { .editor {
width: 100%; width: calc(100% - 1.25em); /* The width is calculated *inside* the padding, so adjust for it. */
height: 6eM; height: 6eM;
font-size: large; font-size: large;
padding: 0.5em; padding: 0.5em;
margin-bottom: 1em; margin-bottom: 1em;
} }
.truth_edit_form {
margin-bottom: 1em;
}
.graph { .graph {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -47,10 +73,131 @@
top: 25%; 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 { hr {
border: none; border: none;
border-top: 2px solid #9ec5fe; border-top: 2px dotted slategray;
color: #9ec5fe; color: slategray;
overflow: visible; overflow: visible;
text-align: center; text-align: center;
height: 5px; height: 5px;
@ -75,12 +222,9 @@ body > h2 {
padding-left: 0.25eM; padding-left: 0.25eM;
} }
@media (orientation: portrait) { nav {
.truth_list { height: fit-content;
width: 60vw; margin: 0 0.25em;
} border-left: 0.15em solid slategray;
border-right: 0.15em solid slategray;
.graph {
width: 35vw;
}
} }

View file

@ -1,50 +1,67 @@
const limit_ratio = 1.3
// const names = ["Bystus", "Dory", "Fen", "Lucky", "Nico", "Peran", "trot"] const colors = ['#b6b8fc', '#b6f4fc', '#fcb6cc', '#e0fcb6', '#fcdcb6', '#b6fcc8', '#f0b6fc']
//
//
// 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))})
// }
async function main() { 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) { if (!vote_response.ok) {
return; 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 = [] let datasets = []
try { try {
const vote_data = (await vote_response.json()).votes; const vote_data = (await vote_response.json());
for (let player in vote_data) { for (let player in vote_data.votes) {
datasets.push({ datasets.push({
parsing: true, parsing: true,
label: player, label: player,
data: vote_data[player], data: vote_data.votes[player],
}) })
} }
for (let i = 1; i <= vote_data.truth_count; i++) {
keys.push("Vérité " + i)
}
} catch (error) { } catch (error) {
console.error("Failed to parse vote data : \n\t" + error.message); console.error("Failed to parse vote data : \n\t" + error.message);
return; 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") const chart_canvas = document.getElementById("vote_chart")
let chart let chart
let previous_orientation
function create_chart(keys, data) { function create_chart(keys, data) {
let main_axis; let main_axis;
let aspect_ratio; let aspect_ratio;
if (window.innerWidth > window.innerHeight) { let orientation
if (window.innerWidth / window.innerHeight > limit_ratio) {
orientation = "landscape"
main_axis = 'x' main_axis = 'x'
aspect_ratio = 2 aspect_ratio = 2
} else { } else {
orientation = "portrait"
main_axis = 'y' main_axis = 'y'
aspect_ratio = 0.5 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 ) { if ( chart ) {
chart.destroy() chart.destroy()
} }
@ -66,6 +83,13 @@ async function main() {
y: { y: {
stacked: true stacked: true
} }
},
plugins: {
title: {
display: true,
position: 'bottom',
text: 'Répartition des suppositions'
}
} }
} }
}) })
@ -75,7 +99,7 @@ async function main() {
create_chart(keys, datasets) 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 orientation_query.onchange = update_chart_ratio
create_chart(keys, datasets) create_chart(keys, datasets)

View file

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="fr">
{% set title = "Vérités Nova Borealis" %}
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
<link href="/static/style.css" rel="stylesheet"/>
<link href="/static/favicon.ico" rel="icon"/>
{% block scripts %}
{% endblock %}
</head>
<body>
{% block top_bar %}
<div class="top_bar">
<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>
<button form="logout">Déconnecter</button>
</form>
{% else %}
<form class="login" id="login" action="/{{ week_data.number }}/login" method="POST">
<label>Pseudo <input form="login" type="text" name="name"/></label>
<label>Mot de passe <input form="login" type="password" name="password"/></label>
<button form="login">Se connecter</button>
</form>
{% endif %}
</div>
{% endblock %}
</div>
</div>
{% endblock %}
{% block body %}
{% endblock %}
</body>
</html>

View file

@ -1,8 +0,0 @@
<div class="individual_truth">
<h3>Vérité {{ truth.number }}</h3>
<p>{{ truth.rendered_text | safe }}</p>
<hr/>
<form action="/{{ week_data.number }}/edit/{{ truth.number }}" method="POST">
{% include "truth_editor" %}
</form>
</div>

View file

@ -1,109 +0,0 @@
<!DOCTYPE html>
<html lang="en">
{% set title = "Vérités Fabula Ultima" %}
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
<link href="/style.css" rel="stylesheet"/>
{% if user.logged_in == true and not user.is_admin %}
<script defer="defer" type="text/javascript" src="/vote_handler.js"></script>
{% endif %}
<script defer="defer" type="application/javascript" src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script defer="defer" type="text/javascript" src="/vote_chart.js"></script>
</head>
{# 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 -%}
<body>
<div class="top_bar">
<h1>{{ title }}</h1>
{% if user.logged_in == true %}
<p>Connecté en tant que <b>{{ user.name }}</b></p>
{% else %}
<form class="login" id="login" action="/login" method="POST">
<label>Pseudo <input form="login" type="text" name="name"/></label>
<label>Mot de passe <input form="login" type="password" name="password"/></label>
<button form="login">Se connecter</button>
</form>
{% endif %}
</div>
<h2>Semaine {{ week_data.number }}</h2>
<div class="page_body">
<div class="truth_list">
<div class="week_intro">
{{ week_data.rendered_text | safe }}
{%- if user.is_admin == true -%}
<hr/>
<form action="/{{ week_data.number }}/edit" method="post">
<textarea class="editor" name="raw_intro">
{{- week_data.raw_text -}}
</textarea>
<button>
Modifier l'introduction
</button>
</form>
{% endif %}
</div>
{% if user.logged_in == true and user.is_admin == false %}
<form id="truths" action="/{{ week_data.number }}/vote" method="POST">
{% 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 %}
<br/>
<button form="truths">
{%- if has_vote == true -%}
Changer de vote
{% else %}
À voter !
{% endif %}
</button>
</form>
{% endif %}
{# If admin, show an additional box for creating a new Truth. #}
{% if user.is_admin == true %}
<div class="individual_truth">
<h3>Nouvelle vérité</h3>
<form action="/{{ week_data.number }}/new" method="POST">
{% include "truth_editor" %}
</form>
</div>
{% endif %}
</div>
<div class="graph">
<div>
<canvas id="vote_chart"></canvas>
</div>
</div>
</div>
</body>
</html>

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,26 +0,0 @@
<div class="individual_truth">
<h3>Vérité {{ truth.number }}</h3>
<p>{{ truth.rendered_text | safe }}</p>
{% if user.logged_in %}
<hr/>
<label>
{%- if truth.author_id == user.id -%}
Tu l'as fait :)
{%- else -%}
Qui l'a fait ?
<select form="truths" name="truth_votes[{{ truth.id }}]">
<option value="0">---</option>
{% for player in other_players %}
{# Check if we should pre-select an existing vote #}
{% if has_vote == true and player.id == user.votes[truth_index].voted_id %}
{% set is_selected = "selected" %}
{% else %}
{% set is_selected = "" %}
{% endif %}
<option value="{{ player.id }}" {{- is_selected -}}>{{ player.name }}</option>
{% endfor %}
</select>
{%- endif -%}
</label>
{% endif %}
</div>

View file

@ -0,0 +1,32 @@
<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>
<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 class="truth_edit_form" action="/{{ week_data.number }}/edit/{{ truth.number }}" method="POST">
{% include "weeks/truth_editor" %}
</form>
{% if tags | length > 0 and tags | length > truth.tags | length %}
<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>
{% endif %}
</div>

View file

@ -0,0 +1,120 @@
{% extends "base_page" %}
{% block scripts %}
{% if user.logged_in == true and not user.is_admin %}
<script defer="defer" type="text/javascript" src="/static/vote_handler.js"></script>
{% endif %}
<script defer="defer" type="application/javascript" src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script defer="defer" type="text/javascript" src="/static/vote_chart.js"></script>
{% 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 -%}
<a {{ target }} class="{{ class }}">{%- if enabled == true -%}{{- display_character -}}{%- endif -%}</a>
{%- 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 %}
<h2>{{- 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) -}}</h2>
<div class="truth_page_body">
<div class="truth_list">
{% if user.is_admin == true and week_data.is_last_week != true %}
<form action="/{{ week_data.number }}/set_last" method="post">
<button>
Définir comme dernière semaine active
</button>
</form>
{% endif %}
<div class="week_intro">
{{ week_data.rendered_text | safe }}
{%- if user.is_admin == true -%}
<hr/>
<form action="/{{ week_data.number }}/edit" method="post">
<textarea class="editor" name="raw_intro">
{{- week_data.raw_text -}}
</textarea>
<button>
Modifier l'introduction
</button>
</form>
{% endif %}
</div>
{% if user.logged_in == true and user.is_admin == false and not lock_truth_form %}
<form id="truths" action="/{{ week_data.number }}/vote" method="POST">
{% 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 %}
<br/>
<button form="truths">
{%- if user.logged_in == true and vote_data.has_week_vote == true -%}
Changer de vote
{% else %}
À voter !
{% endif %}
</button>
</form>
{% endif %}
{# If admin, show an additional box for creating a new Truth. #}
{% if user.is_admin == true %}
<div class="individual_truth">
<h3>Nouvelle vérité</h3>
<form action="/{{ week_data.number }}/new_truth" method="POST">
{% include "weeks/truth_editor" %}
</form>
</div>
{% endif %}
</div>
<div class="graph">
<div>
<canvas id="vote_chart"></canvas>
</div>
</div>
</div>
{% endblock %}

View file

@ -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 -%}
<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>
{%- if truth.author_id == user.id -%}
Tu l'as fait :)
{%- else -%}
Qui l'a fait ?
<select form="truths" name="truth_votes[{{ truth.id }}]" {{ is_disabled }}>
<option value="0">---</option>
{% for player in other_players %}
{# Check if we should pre-select an existing vote. #}
{% set_global is_selected = "" %}
{% for vote in vote_data.votes %}
{% if truth.id == vote.truth_id and player.id == vote.voted_id %}
{% set_global is_selected = "selected" %}
{% break %}
{% endif %}
{% endfor %}
<option value="{{ player.id }}" {{- is_selected -}}>{{ player.name }}</option>
{% endfor %}
</select>
{%- endif -%}
</label>
{% endif %}
</div>