Compare commits

..

No commits in common. "main" and "add-paginate" have entirely different histories.

33 changed files with 242 additions and 1323 deletions

2
.gitignore vendored
View File

@ -1,3 +1 @@
/target
.DS_Store

View File

@ -1,6 +1,4 @@
database_path = ""
locale = "fr"
base_url = ""
[listener]
port = 8000

312
Cargo.lock generated
View File

@ -99,15 +99,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "arc-swap"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e"
dependencies = [
"rustversion",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
@ -296,12 +287,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "base62"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1adf9755786e27479693dedd3271691a92b5e242ab139cacb9fb8e7fb5381111"
[[package]]
name = "base64"
version = "0.22.1"
@ -337,12 +322,6 @@ dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.10.0"
@ -381,11 +360,9 @@ dependencies = [
"askama_web",
"axum",
"camino",
"csv",
"dirs",
"log",
"pretty_env_logger",
"rust-i18n",
"sea-orm",
"sea-orm-migration",
"serde",
@ -393,7 +370,7 @@ dependencies = [
"snafu",
"static-serve",
"tokio",
"toml 0.9.11+spec-1.1.0",
"toml",
"xdg",
]
@ -420,16 +397,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.19.1"
@ -615,25 +582,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@ -659,27 +607,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "csv"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "csv-core"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
dependencies = [
"memchr",
]
[[package]]
name = "darling"
version = "0.20.11"
@ -1069,30 +996,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "globset"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "globwalk"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc"
dependencies = [
"bitflags 1.3.2",
"ignore",
"walkdir",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@ -1399,22 +1302,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "ignore"
version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@ -1475,15 +1362,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
@ -1546,7 +1424,7 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags 2.10.0",
"bitflags",
"libc",
"redox_syscall 0.7.0",
]
@ -1651,15 +1529,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "normpath"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@ -1915,7 +1784,7 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
dependencies = [
"toml_edit 0.23.10+spec-1.0.0",
"toml_edit",
]
[[package]]
@ -2039,7 +1908,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.10.0",
"bitflags",
]
[[package]]
@ -2048,7 +1917,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27"
dependencies = [
"bitflags 2.10.0",
"bitflags",
]
[[package]]
@ -2169,60 +2038,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rust-i18n"
version = "3.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332"
dependencies = [
"globwalk",
"once_cell",
"regex",
"rust-i18n-macro",
"rust-i18n-support",
"smallvec",
]
[[package]]
name = "rust-i18n-macro"
version = "3.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965"
dependencies = [
"glob",
"once_cell",
"proc-macro2",
"quote",
"rust-i18n-support",
"serde",
"serde_json",
"serde_yaml",
"syn 2.0.114",
]
[[package]]
name = "rust-i18n-support"
version = "3.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19"
dependencies = [
"arc-swap",
"base62",
"globwalk",
"itertools 0.11.0",
"lazy_static",
"normpath",
"once_cell",
"proc-macro2",
"regex",
"serde",
"serde_json",
"serde_yaml",
"siphasher",
"toml 0.8.23",
"triomphe",
]
[[package]]
name = "rust_decimal"
version = "1.40.0"
@ -2266,15 +2081,6 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schemars"
version = "0.9.0"
@ -2330,7 +2136,7 @@ dependencies = [
"chrono",
"derive_more",
"futures-util",
"itertools 0.14.0",
"itertools",
"log",
"ouroboros",
"pgvector",
@ -2534,15 +2340,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.0.4"
@ -2595,19 +2392,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.13.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "sha1"
version = "0.10.6"
@ -2667,12 +2451,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "slab"
version = "0.4.11"
@ -2835,7 +2613,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
"bitflags 2.10.0",
"bitflags",
"byteorder",
"bytes",
"chrono",
@ -2881,7 +2659,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
"bitflags 2.10.0",
"bitflags",
"byteorder",
"chrono",
"crc",
@ -3185,18 +2963,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml"
version = "0.9.11+spec-1.1.0"
@ -3205,22 +2971,13 @@ checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
dependencies = [
"indexmap 2.13.0",
"serde_core",
"serde_spanned 1.0.4",
"toml_datetime 0.7.5+spec-1.1.0",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@ -3230,20 +2987,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.13.0",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow",
]
[[package]]
name = "toml_edit"
version = "0.23.10+spec-1.0.0"
@ -3251,7 +2994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
dependencies = [
"indexmap 2.13.0",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_datetime",
"toml_parser",
"winnow",
]
@ -3265,12 +3008,6 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
@ -3352,17 +3089,6 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "triomphe"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39"
dependencies = [
"arc-swap",
"serde",
"stable_deref_trait",
]
[[package]]
name = "typenum"
version = "1.19.0"
@ -3408,12 +3134,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "url"
version = "2.5.8"
@ -3461,16 +3181,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"

View File

@ -31,5 +31,3 @@ pretty_env_logger = "0.5.0"
# custom logger
log = "0.4.29"
serde_with = "3.16.1"
csv = "1.4.0"
rust-i18n = "3.1.5"

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -1,92 +0,0 @@
_version: 1
name: "BookForge"
common:
download: Download
create: Create
edit: Edit
delete: Delete
next: Next
previous: Previous
actions: Actions
search: Search
reset: Reset
show: Show
no_result: No results
close: Close
confirmation: Confirmation
are_you_sure: Are you sure?
nav:
toggle: Toggle navigation
books: Books
users: Users
theme:
light: Light
dark: Dark
user:
attributes:
name: Name
owner_books: Owned books
borrowed_books: Borrowed books
index:
title_tag: Users list | BookForge
title: All Users
button: Add a user
edit:
title_tag: Edit user | BookForge
title: Edit
button: Edit user
new:
title_tag: New user | BookForge
title: New user
button: Create user
book:
attributes:
title: Title
authors: Author(s)
description: Description
owner: Owner
current_holder: Current holder
comment: Comment
index:
title_tag: Books list | BookForge
title: All Books
new:
title_tag: New book | BookForge
title: New Book
button: Create book
button_short: Add book
edit:
title_tag: Edit book | BookForge
title: Edit book
button: Edit book
show:
title_tag: Details | BookForge
book_details: Book details
user_details: User details
more_informations: More information
footer:
message: Made with love & Fuck fascists!
error:
error_404:
title_tag: Error 404 | BookForge
title: Oops! This page does not exist
subtitle: 404 NOT FOUND
button: Back to home
generic:
title_tag: Error | BookForge
title: Oops! An error occurred

View File

@ -1,77 +0,0 @@
_version: 1
name: "BookForge"
common:
download: Télécharger
create: Créer
edit: Modifier
delete: Supprimer
next: Suivant
previous: Précédent
actions: Actions
search: Filtrer
reset: Réini.
show: Voir
no_result: Aucun résultat
close: Fermer
confirmation: Confirmation
are_you_sure: Êtes-vous sûr ?
nav:
toggle: Basculer la navigation
books: Livres
users: Utilisateurs
theme:
light: Light
dark: Dark
user:
attributes:
name: Nom
owner_books: Livres possédés
borrowed_books: Livres empruntés
index:
title_tag: Liste des utilisateur.ice.s | BookForge
title: Tous les utilisateur.ice.s
button: Ajouter un.e utilisateur.ice
edit:
title_tag: Modifier l'utilisateur.ice | BookForge
title: Modifier
button: Modifier l'utilisateur.ice
new:
title_tag: Nouvel utilisateur.ice | BookForge
title: Nouvel utilisateur.ice
button: Créer l'utilisateur.ice
book:
attributes:
title: Titre
authors: Auteur.ice.(s)
description: Description
owner: Propriétaire
current_holder: Détenteur.ice actuel.le
comment: Commentaire
index:
title_tag: Liste des livres | BookForge
title: Tous les livres
new:
title_tag: Nouveau livre | BookForge
title: Nouveau livre
button: Créer le livre
button_short: Ajouter un livre
edit:
title_tag: Modifier le livre | BookForge
title: Modifier le livre
button: Modifier le livre
show:
title_tag: Details | BookForge
book_details: Détails du livre
user_details: Détails de l'utilisateur.ice
more_informations: Plus d'informations
footer:
message: Fait avec amour & Nique les fachos !
error:
error_404:
title_tag: Erreur 404 | BookForge
title: Oups ! Cette page n'existe pas
subtitle: 404 NOT FOUND
button: Retour à l'accueil
generic:
title_tag: Erreur | BookForge
title: Oups ! Une erreur s'est produite

View File

@ -1,13 +1,9 @@
use askama::Template;
use askama_web::WebTemplate;
use axum::{
Router,
extract::State,
routing::{get, post},
};
use static_serve::embed_assets;
use crate::routes::router::Router as InternalRouter;
use crate::state::AppState;
mod migrations;
@ -15,45 +11,20 @@ mod models;
mod routes;
pub mod state;
#[macro_use]
extern crate rust_i18n;
i18n!("locales", fallback = "en", minify_key = true);
pub fn build_app(state: AppState) -> Router {
rust_i18n::set_locale(&state.config.locale);
embed_assets!("assets", compress = true);
Router::new()
.route("/", get(routes::book::index))
.route("/books/new", get(routes::book::new))
.route("/books", post(routes::book::create))
.route("/books/{id}", get(routes::book::show))
.route("/books/{id}", post(routes::book::update))
.route("/books/{id}/delete", post(routes::book::delete))
.route("/books/{id}/edit", get(routes::book::edit))
.route("/books/download_csv", get(routes::book::download_csv))
.route("/books/new", get(routes::book::new))
.route("/users", get(routes::user::index))
.route("/users/new", get(routes::user::new))
.route("/users/{id}/edit", get(routes::user::edit))
.route("/users/{id}", post(routes::user::update))
.route("/users", post(routes::user::create))
.route("/users/{id}/delete", post(routes::user::delete))
.route("/users/{id}", post(routes::user::delete))
.nest("/assets", static_router())
.fallback(error_handler)
.with_state(state)
}
#[derive(Template, WebTemplate)]
#[template(path = "404.html")]
struct NotFoundTemplate {
pub router: InternalRouter,
}
pub async fn error_handler(State(state): State<AppState>) -> impl axum::response::IntoResponse {
NotFoundTemplate {
router: InternalRouter {
base_path: state.config.base_path,
},
}
}

View File

@ -70,7 +70,7 @@ impl BookOperator {
/// Lists all books matching the optional query filters.
///
/// Results are ordered by ID in descending order (newest first).
pub async fn all(&self) -> Result<Vec<Model>, BookError> {
pub async fn list(&self) -> Result<Vec<Model>, BookError> {
Entity::find()
.order_by_desc(Column::Id)
.all(&self.state.db)
@ -78,18 +78,7 @@ impl BookOperator {
.context(DBSnafu)
}
pub async fn all_filtered(&self, query: Option<IndexQuery>) -> Result<Vec<Model>, BookError> {
let conditions = Self::filter_conditions(query);
Entity::find()
.filter(conditions)
.order_by_desc(Column::Id)
.all(&self.state.db)
.await
.context(DBSnafu)
}
pub async fn all_paginate(
pub async fn list_paginate(
&self,
page: u64,
query: Option<IndexQuery>,
@ -97,12 +86,29 @@ impl BookOperator {
let page = if page > 0 { page } else { 1 }; // keep 1-indexed
let page_0indexed = page - 1; // convert for SeaORM (0-based index)
let conditions = Self::filter_conditions(query);
let mut conditions = Condition::all();
if let Some(book_query) = query {
if let Some(title) = book_query.title {
conditions = conditions.add(Column::Title.contains(&title));
}
if let Some(authors) = book_query.authors {
conditions = conditions.add(Column::Authors.contains(&authors));
}
if let Some(owner_id) = book_query.owner_id {
conditions = conditions.add(Column::OwnerId.eq(owner_id));
}
if let Some(current_holder_id) = book_query.current_holder_id {
conditions = conditions.add(Column::CurrentHolderId.eq(current_holder_id));
}
}
let book_pages = Entity::find()
.filter(conditions)
.order_by_desc(Column::Id)
.paginate(&self.state.db, 100);
.paginate(&self.state.db, 1);
let books = book_pages
.fetch_page(page_0indexed)
@ -134,27 +140,6 @@ impl BookOperator {
}
}
/// Finds vec of book by its Owner
pub async fn find_all_by_owner(&self, owner_id: i32) -> Result<Vec<Model>, BookError> {
Entity::find()
.filter(Column::OwnerId.eq(owner_id))
.all(&self.state.db)
.await
.context(DBSnafu)
}
/// Finds vec of book by its Owner
pub async fn find_all_by_current_holder(
&self,
current_holder_id: i32,
) -> Result<Vec<Model>, BookError> {
Entity::find()
.filter(Column::CurrentHolderId.eq(current_holder_id))
.all(&self.state.db)
.await
.context(DBSnafu)
}
/// Creates a new book from the given form data.
pub async fn create(&self, form: BookForm) -> Result<Model, BookError> {
let book = ActiveModel {
@ -203,28 +188,4 @@ impl BookOperator {
book.delete(&self.state.db).await.context(DBSnafu)
}
// private
fn filter_conditions(query: Option<IndexQuery>) -> Condition {
let mut conditions = Condition::all();
if let Some(book_query) = query {
if let Some(title) = book_query.title {
conditions = conditions.add(Column::Title.contains(&title));
}
if let Some(authors) = book_query.authors {
conditions = conditions.add(Column::Authors.contains(&authors));
}
if let Some(owner_id) = book_query.owner_id {
conditions = conditions.add(Column::OwnerId.eq(owner_id));
}
if let Some(current_holder_id) = book_query.current_holder_id {
conditions = conditions.add(Column::CurrentHolderId.eq(current_holder_id));
}
}
return conditions;
}
}

View File

@ -1,11 +1,6 @@
use crate::models::book;
use crate::routes::book::BookForm;
use crate::routes::user::IndexQuery;
use crate::routes::user::UserForm;
use crate::state::AppState;
use crate::state::error::UserSnafu;
use sea_orm::ActiveValue::Set;
use sea_orm::Condition;
use sea_orm::DeleteResult;
use sea_orm::entity::prelude::*;
use snafu::ResultExt;
@ -41,8 +36,6 @@ pub enum UserError {
DB { source: sea_orm::DbErr },
#[snafu(display("User with id {id} not found"))]
NotFound { id: i32 },
#[snafu(display("Book error"))]
Book { source: super::book::BookError },
}
#[derive(Debug)]
@ -55,23 +48,10 @@ impl UserOperator {
Self { state }
}
pub async fn all(&self) -> Result<Vec<Model>, UserError> {
pub async fn list(&self) -> Result<Vec<Model>, UserError> {
Entity::find().all(&self.state.db).await.context(DBSnafu)
}
pub async fn all_filtered(&self, query: IndexQuery) -> Result<Vec<Model>, UserError> {
let mut conditions = Condition::all();
if let Some(name) = query.name {
conditions = conditions.add(Column::Name.contains(name))
}
Entity::find()
.filter(conditions)
.all(&self.state.db)
.await
.context(DBSnafu)
}
pub async fn find_by_id(&self, id: i32) -> Result<Model, UserError> {
let user: Option<Model> = Entity::find_by_id(id)
.one(&self.state.db)
@ -94,60 +74,7 @@ impl UserOperator {
user.insert(&self.state.db).await.context(DBSnafu)
}
pub async fn update(&self, id: i32, form: UserForm) -> Result<Model, UserError> {
let user_by_id = Self::find_by_id(&self, id).await.context(UserSnafu);
if let Ok(user) = user_by_id {
let mut user: ActiveModel = user.into();
user.name = Set(form.name);
user.update(&self.state.db).await.context(DBSnafu)
} else {
Err(UserError::NotFound { id })
}
}
/// Delete user by ID.
/// Before deleting the user, you must search for all the books they own in order to delete them beforehand,
/// then search for all the books they have borrowed in order to update the current holder to None.
pub async fn delete(&self, user_id: i32) -> Result<DeleteResult, UserError> {
// get all
let owner_books = book::BookOperator::new(self.state.clone())
.find_all_by_owner(user_id)
.await
.context(BookSnafu)?;
// Delete all book with owner_id = current_user
for owner_book in owner_books {
book::BookOperator::new(self.state.clone())
.delete(owner_book.id)
.await
.context(BookSnafu)?;
}
let current_holder_books = book::BookOperator::new(self.state.clone())
.find_all_by_current_holder(user_id)
.await
.context(BookSnafu)?;
// Update all book with current Holder = current user
for current_holder_book in current_holder_books {
let form = BookForm {
title: current_holder_book.title,
authors: current_holder_book.authors,
owner_id: current_holder_book.owner_id,
description: current_holder_book.description,
comment: current_holder_book.comment,
current_holder_id: None,
};
book::BookOperator::new(self.state.clone())
.update(current_holder_book.id, form)
.await
.context(BookSnafu)?;
}
let user: Option<Model> = Entity::find_by_id(user_id)
.one(&self.state.db)
.await

View File

@ -4,17 +4,15 @@ use askama::Template;
use askama_web::WebTemplate;
use axum::{
Form,
body::Body,
extract::{Path, Query, State},
response::{IntoResponse, Redirect, Response},
response::{IntoResponse, Redirect},
};
use csv::Writer;
use serde::Deserialize;
use serde_with::{NoneAsEmptyString, serde_as};
use snafu::prelude::*;
use crate::{models::book::Model as BookModel, routes::router::Router, state::error::CSVSnafu};
use crate::{models::user::Model as UserModel, state::error::IOSnafu};
use crate::models::book::Model as BookModel;
use crate::models::user::Model as UserModel;
use crate::{
models::{book::BookOperator, user::UserOperator},
@ -55,7 +53,6 @@ struct BookIndexTemplate {
current_page: u64,
total_page: u64,
base_query: String,
router: Router,
}
pub async fn index(
@ -69,13 +66,13 @@ pub async fn index(
// Get all Users
let users = UserOperator::new(state.clone())
.all()
.list()
.await
.context(UserSnafu)?;
// Get all Book filtered with query
let books_paginate = BookOperator::new(state.clone())
.all_paginate(page, Some(query.clone()))
let books_paginate = BookOperator::new(state)
.list_paginate(page, Some(query.clone()))
.await
.context(BookSnafu)?;
@ -128,9 +125,6 @@ pub async fn index(
current_page: books_paginate.current_page,
total_page: books_paginate.total_page,
base_query,
router: Router {
base_path: state.config.base_path,
},
})
}
@ -140,7 +134,6 @@ struct ShowBookTemplate {
book: BookModel,
owner: UserModel,
current_holder: Option<UserModel>,
router: Router,
}
pub async fn show(
@ -173,9 +166,6 @@ pub async fn show(
book,
owner,
current_holder,
router: Router {
base_path: state.config.base_path,
},
})
}
@ -208,23 +198,14 @@ pub async fn create(
#[template(path = "books/new.html")]
struct NewBookTemplate {
users: Vec<UserModel>,
router: Router,
}
pub async fn new(
State(state): State<AppState>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let users = UserOperator::new(state.clone())
.all()
.await
.context(UserSnafu)?;
let users = UserOperator::new(state).list().await.context(UserSnafu)?;
Ok(NewBookTemplate {
users,
router: Router {
base_path: state.config.base_path,
},
})
Ok(NewBookTemplate { users })
}
#[derive(Template, WebTemplate)]
@ -232,7 +213,6 @@ pub async fn new(
struct EditBookTemplate {
users: Vec<UserModel>,
book: BookModel,
router: Router,
}
pub async fn edit(
@ -240,21 +220,15 @@ pub async fn edit(
Path(id): Path<i32>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let users = UserOperator::new(state.clone())
.all()
.list()
.await
.context(UserSnafu)?;
let book = BookOperator::new(state.clone())
let book = BookOperator::new(state)
.find_by_id(id)
.await
.context(BookSnafu)?;
Ok(EditBookTemplate {
users,
book,
router: Router {
base_path: state.config.base_path,
},
})
Ok(EditBookTemplate { users, book })
}
pub async fn update(
@ -280,75 +254,3 @@ pub async fn delete(
Ok(Redirect::to("/").into_response())
}
/// Download CSV filter (no paginate) of all books
pub async fn download_csv(
State(state): State<AppState>,
Query(query): Query<IndexQuery>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let books = BookOperator::new(state.clone())
.all_filtered(Some(query))
.await
.context(BookSnafu)?;
let users = UserOperator::new(state).all().await.context(UserSnafu)?;
let users_by_id: HashMap<i32, UserModel> = users.into_iter().map(|u| (u.id, u)).collect();
let mut wtr = Writer::from_writer(vec![]);
wtr.write_record(&[
"ID",
"Title",
"Author(s)",
"Description",
"Owner",
"Current Holder",
"Comment",
])
.context(CSVSnafu)?;
for book in books {
let owner_format = match users_by_id.get(&book.owner_id).cloned().ok_or(UserSnafu) {
Ok(owner) => format!("{} (id: {})", owner.name.to_string(), owner.id),
Err(_) => "-".to_string(),
};
let current_holder = match users_by_id
// if current_holder_id is None, take 0.
// So get returns errors because user with id 0 can't exist
.get(&book.current_holder_id.unwrap_or(0))
.cloned()
.ok_or(UserSnafu)
{
Ok(current_holder) => format!(
"{} (id: {})",
current_holder.name.to_string(),
current_holder.id
),
Err(_) => "-".to_string(),
};
wtr.write_record(&[
book.id.to_string(),
book.title,
book.authors,
book.description.unwrap_or_default(),
owner_format,
current_holder,
book.comment.unwrap_or_default(),
])
.context(CSVSnafu)?;
}
wtr.flush().context(IOSnafu)?;
let csv_bytes = wtr.into_inner();
match csv_bytes {
Ok(csv_bytes) => Ok(Response::builder()
.header("Content-Type", "text/csv")
.body(Body::from(csv_bytes))
.unwrap()),
Err(_) => Ok(Redirect::to("/").into_response()),
}
}

View File

@ -1,3 +1,2 @@
pub mod book;
pub mod router;
pub mod user;

View File

@ -1,54 +0,0 @@
#[derive(Clone)]
pub struct Router {
pub base_path: String,
}
impl Router {
pub fn assets(&self, path: &str) -> String {
if self.base_path.is_empty() || self.base_path == "/" {
format!("/{}", path)
} else {
format!("{}/{}", self.base_path.trim_end_matches('/'), path)
}
}
pub fn root_path(&self) -> String {
format!("{}/", &self.base_path)
}
// BOOKS ROUTES
pub fn new_book_path(&self) -> String {
format!("{}/books/new", &self.base_path)
}
pub fn create_book_path(&self) -> String {
format!("{}/books", &self.base_path)
}
pub fn update_book_path(&self, id: &i32) -> String {
format!("{}/books/{}", &self.base_path, id)
}
pub fn download_csv_book_path(&self) -> String {
format!("{}/books/download_csv", &self.base_path)
}
// USERS
pub fn index_user_path(&self) -> String {
format!("{}/users", &self.base_path)
}
pub fn new_user_path(&self) -> String {
format!("{}/users/new", &self.base_path)
}
pub fn create_user_path(&self) -> String {
format!("{}/users", &self.base_path)
}
pub fn update_user_path(&self, id: &i32) -> String {
format!("{}/users/{}", &self.base_path, id)
}
}

View File

@ -4,11 +4,10 @@ use askama::Template;
use askama_web::WebTemplate;
use axum::{
Form,
extract::{Path, Query, State},
extract::{Path, State},
response::Redirect,
};
use serde::Deserialize;
use serde_with::{NoneAsEmptyString, serde_as};
use snafu::prelude::*;
use crate::{
@ -16,7 +15,6 @@ use crate::{
book::BookOperator,
user::{self, UserOperator},
},
routes::router::Router,
state::{
AppState,
error::{AppStateError, BookSnafu, UserSnafu},
@ -26,43 +24,23 @@ use crate::{
#[derive(Template, WebTemplate)]
#[template(path = "users/index.html")]
struct UsersIndexTemplate {
users_with_books_number: Vec<UserWithBookNumber>,
query: IndexQuery,
router: Router,
}
pub struct UserWithBookNumber {
/// the user model
pub user: user::Model,
/// the number of books owned by this user
pub owner_book_number: usize,
/// the number of books borrowed by this user
pub borrowed_book_number: usize,
}
#[serde_as]
#[derive(Deserialize, Clone)]
pub struct IndexQuery {
#[serde(default)]
#[serde_as(as = "NoneAsEmptyString")]
pub name: Option<String>,
user_with_books_number: Vec<(user::Model, usize, usize)>,
}
pub async fn index(
State(state): State<AppState>,
Query(query): Query<IndexQuery>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let users = UserOperator::new(state.clone())
.all_filtered(query.clone())
.list()
.await
.context(UserSnafu)?;
let books = BookOperator::new(state.clone())
.all()
.list()
.await
.context(BookSnafu)?;
let mut result: Vec<UserWithBookNumber> = Vec::with_capacity(users.len());
let mut result: Vec<(user::Model, usize, usize)> = vec![];
let mut owner_books: HashMap<i32, usize> = HashMap::new();
let mut borrowed_books: HashMap<i32, usize> = HashMap::new();
@ -78,19 +56,11 @@ pub async fn index(
let owner_books_size = owner_books.get(&user.id).unwrap_or(&0);
let borrowed_books_size = borrowed_books.get(&user.id).unwrap_or(&0);
result.push(UserWithBookNumber {
user,
owner_book_number: *owner_books_size,
borrowed_book_number: *borrowed_books_size,
});
result.push((user, *owner_books_size, *borrowed_books_size));
}
Ok(UsersIndexTemplate {
users_with_books_number: result,
query,
router: Router {
base_path: state.config.base_path,
},
user_with_books_number: result,
})
}
@ -103,7 +73,7 @@ pub async fn create(
State(state): State<AppState>,
Form(form): Form<UserForm>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let _ = UserOperator::new(state)
let _user = UserOperator::new(state)
.create(form)
.await
.context(UserSnafu)?;
@ -111,19 +81,6 @@ pub async fn create(
Ok(Redirect::to("/users"))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i32>,
Form(form): Form<UserForm>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let _ = UserOperator::new(state)
.update(id, form)
.await
.context(UserSnafu)?;
Ok(Redirect::to("/users"))
}
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i32>,
@ -135,41 +92,3 @@ pub async fn delete(
Ok(Redirect::to("/users"))
}
#[derive(Template, WebTemplate)]
#[template(path = "users/edit.html")]
struct EditTemplate {
user: user::Model,
router: Router,
}
pub async fn edit(
State(state): State<AppState>,
Path(id): Path<i32>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let user = UserOperator::new(state.clone())
.find_by_id(id)
.await
.context(UserSnafu)?;
Ok(EditTemplate {
user,
router: Router {
base_path: state.config.base_path,
},
})
}
#[derive(Template, WebTemplate)]
#[template(path = "users/new.html")]
struct NewTemplate {
router: Router,
}
pub async fn new(State(state): State<AppState>) -> impl axum::response::IntoResponse {
NewTemplate {
router: Router {
base_path: state.config.base_path,
},
}
}

View File

@ -32,8 +32,6 @@ pub enum ConfigError {
pub struct AppConfig {
#[serde(default = "AppConfig::default_sqlite_path")]
pub database_path: Utf8PathBuf,
pub locale: String,
pub base_path: String,
pub listener: Listener,
}
@ -41,8 +39,6 @@ impl Default for AppConfig {
fn default() -> Self {
AppConfig {
database_path: Self::default_sqlite_path(),
base_path: Self::default_base_path(),
locale: Self::default_locale(),
listener: Listener::default(),
}
}
@ -96,14 +92,6 @@ impl AppConfig {
Self::config_path().join("BookForge.toml")
}
fn default_locale() -> String {
"en".to_string()
}
fn default_base_path() -> String {
"".to_string()
}
pub fn default_sqlite_path() -> Utf8PathBuf {
Self::config_path().join("db.sqlite")
}

View File

@ -1,15 +1,17 @@
use askama::Template;
use askama_web::WebTemplate;
use axum::response::{IntoResponse, Response};
use log::error;
use snafu::prelude::*;
use crate::{
models::{book::BookError, user::UserError},
routes::router::Router,
state::config::ConfigError,
};
#[derive(Template, WebTemplate)]
#[template(path = "error.html")]
struct ErrorTemplate {}
#[derive(Snafu, Debug)]
#[snafu(visibility(pub))]
pub enum AppStateError {
@ -34,44 +36,10 @@ pub enum AppStateError {
Book {
source: BookError,
},
#[snafu(display("CSV Error"))]
CSV {
source: csv::Error,
},
#[snafu(display("IO Error"))]
IO {
source: std::io::Error,
},
}
#[derive(Template, WebTemplate)]
#[template(path = "error.html")]
struct ErrorTemplate {
state: AppStateErrorContext,
router: Router,
}
struct AppStateErrorContext {
pub errors: Vec<AppStateError>,
}
impl From<AppStateError> for AppStateErrorContext {
fn from(e: AppStateError) -> Self {
error!("{:?}", e);
Self { errors: vec![e] }
}
}
impl IntoResponse for AppStateError {
fn into_response(self) -> Response {
let error_context = AppStateErrorContext::from(self);
ErrorTemplate {
state: error_context,
router: Router {
base_path: "".to_string(),
},
}
.into_response()
ErrorTemplate {}.into_response()
}
}

View File

@ -1,13 +0,0 @@
{% extends "base.html" %}
{% block title %}
{{ t!("error.error_404.title_tag") }}
{% endblock %}
{% block main %}
<div class="mt-4 text-center">
<h1>{{ t!("error.error_404.title") }}</h1>
<h2 class="fst-italic">{{ t!("error.error_404.subtitle") }}</h2>
<a href="{{ router.root_path() }}" class="mt-3 btn btn-info">{{ t!("error.error_404.button") }}</a>
</div>
{% endblock %}

View File

@ -2,14 +2,13 @@
<head>
<meta charset="utf-8">
<title>{% block title %}{{ t!("name") }}{% endblock %}</title>
<title>{% block title %}Book Forge{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href='{{ router.assets("assets/css/bootstrap.css") }}'>
<link rel="stylesheet" href='{{ router.assets("assets/css/main.css") }}'>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon.png">
<link rel="stylesheet" href="/assets/css/bootstrap.css">
<link rel="stylesheet" href="/assets/css/main.css">
<link rel="stylesheet" href='{{ router.assets("assets/css/fork-awesome.min.css") }}'>
<link rel="icon" type="image/x-icon" href='{{ router.assets("assets/images/favicon.png") }}'>
<link rel="stylesheet" href="/assets/css/fork-awesome.min.css">
<link rel="icon" type="image/x-icon" href="/assets/images/favicon.ico">
{% block extra_head %}{% endblock extra_head %}
</head>
@ -26,7 +25,8 @@
{% endblock %}
</footer>
<script src='{{ router.assets("assets/js/bootstrap.min.js") }}'></script>
<script src='{{ router.assets("assets/js/script.js") }}'></script>
<script src="/assets/js/bootstrap.min.js"></script>
<script src="/assets/js/script.js"></script>
</body>
</html>

View File

@ -2,27 +2,23 @@
{% import "components/typography.html" as typography %}
{% import "components/cards.html" as cards %}
{% block title %}
{{ t!("book.edit.title_tag") }}
{% endblock %}
{% block main %}
{{ typography::heading(t!("book.edit.title")) }}
{{ typography::heading("Editer") }}
{% call cards::card() %}
<form method="post" action="{{ router.update_book_path(&book.id) }}">
<form method="post" action="/books/{{ book.id }}">
<div class="mb-3">
<label class="form-label" for="title">{{ t!("book.attributes.title") }}</label>
<label class="form-label" for="title">Name</label>
<input type="text" name="title" class="form-control" value="{{ book.title }}" required>
</div>
<div class="mb-3">
<label for="authors" class="form-label">{{ t!("book.attributes.authors") }}</label>
<label for="authors" class="form-label">Author(s)</label>
<input type="text" name="authors" class="form-control" value="{{ book.authors }}" required>
</div>
<div class="mb-3">
<label for="owner_id" class="form-label">{{ t!("book.attributes.owner") }}</label>
<label for="owner_id" class="form-label">Owner</label>
<select name="owner_id" class="form-control" required>
{% for user in users %}
{% if book.owner_id == user.id %}
@ -35,7 +31,7 @@
</div>
<div class="mb-3">
<label for="current_holder_id" class="form-label">{{ t!("book.attributes.current_holder") }}</label>
<label for="current_holder_id" class="form-label">Current Holder</label>
<select class="form-control" name="current_holder_id">
<option></option>
{% match book.current_holder_id %}
@ -56,7 +52,7 @@
</div>
<div class="mb-3">
<label for="description" class="form-label">{{ t!("book.attributes.description") }}</label>
<label for="description" class="form-label">Description</label>
{% match book.description %}
{% when Some with (description) %}
<textarea name="description" class="form-control">{{ description }}</textarea>
@ -66,7 +62,7 @@
</div>
<div class="mb-3">
<label class="form-label" for="comment">{{ t!("book.attributes.comment") }}</label>
<label class="form-label" for="comment">Comment</label>
{% match book.comment %}
{% when Some with (comment) %}
<textarea name="comment" class="form-control">{{ comment }}</textarea>
@ -76,7 +72,7 @@
</div>
<div class="mt-4 text-center">
<input type="submit" value='{{ t!("book.edit.button") }}' class="btn btn-success">
<input type="submit" value="Edit book" class="btn btn-success">
</div>
</form>
{% endcall %}

View File

@ -1,34 +1,53 @@
{% extends "base.html" %}
{% import "components/typography.html" as typography %}
{% import "components/cards.html" as cards %}
{% import "components/inputs.html" as form_helpers %}
{% block title %}
{{ t!("book.new.title_tag") }}
{% endblock %}
{% block main %}
{{ typography::heading(t!("book.new.title")) }}
{{ typography::heading("New book") }}
{% call cards::card() %}
<form method="post" action="{{ router.create_book_path() }}">
{{ form_helpers::input("title", t!("book.attributes.title"), is_required = true, placeholder = "Ex: La Petite Dernière") }}
{{ form_helpers::input("authors", t!("book.attributes.authors"), is_required = true, placeholder = "Ex: Fatima Daas") }}
<form method="post" action="/books">
<div class="mb-3">
<label for="title" class="form-label">Name</label>
<input type="text" name="title" class="form-control" required>
</div>
{% call(option) form_helpers::select("owner_id", t!("book.attributes.owner"), users, is_required = true) %}
<option value="{{ option.id }}">{{ option.name }}</option>
{% endcall %}
<div class="mb-3">
<label for="authors" class="form-label">Author(s)</label>
<input type="text" name="authors" class="form-control" required>
</div>
{% call(option) form_helpers::select("current_holder_id", t!("book.attributes.current_holder"), users, is_required = false) %}
<option value="{{ option.id }}">{{ option.name }}</option>
{% endcall %}
<div class="mb-3">
<label for="owner_id" class="form-label">Owner</label>
<select name="owner_id" class="form-control" required>
{% for user in users %}
<option value="{{ user.id }}">{{ user.name }}</option>
{% endfor %}
</select>
</div>
{{ form_helpers::textarea("description", t!("book.attributes.description"), rows = 5, is_required = false, placeholder = "Ex: Je mappelle Fatima Daas. Je suis la mazoziya, la petite dernière. Celle à laquelle on ne sest pas préparé. Française dorigine algérienne.") }}
<div class="mb-3">
<label for="current_holder_id" class="form-label">Current Holder</label>
<select name="current_holder_id" class="form-control">
<option></option>
{% for user in users %}
<option value="{{ user.id }}">{{ user.name }}</option>
{% endfor %}
</select>
</div>
{{ form_helpers::textarea("comment", t!("book.attributes.comment"), rows = 3, is_required = false, placeholder = "Ex: I recommend it, it's great!") }}
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea name="description" class="form-control"></textarea>
</div>
<div class="mb-3">
<label for="comment" class="form-label">Comment</label>
<textarea name="comment" class="form-control"></textarea>
</div>
<div class="mt-4 text-center">
<input type="submit" value='{{ t!("book.new.button") }}' class="btn btn-success">
<input type="submit" value="Create book" class="btn btn-success">
</div>
</form>
{% endcall %}

View File

@ -4,43 +4,39 @@
{% import "components/fields.html" as fields %}
{% import "components/cards.html" as cards %}
{% block title %}
{{ t!("book.show.title_tag") }}
{% endblock %}
{% block main %}
{{ typography::book_heading(book.title, book, show = false) }}
{% call cards::card() %}
<div class="">
<h5 class="fw-bold text-decoration-underline">{{ t!("book.show.book_details") }}</h5>
{{ fields::field(t!("book.attributes.title"), book.title) }}
{{ fields::field(t!("book.attributes.authors"), book.authors) }}
<div class="mt-4">
<h5 class="mt-4 fw-bold">Book details</h5>
{{ fields::field("Name", book.title) }}
{{ fields::field("Authors", book.authors) }}
{% match book.description %}
{% when Some with (description) %}
{{ fields::field(t!("book.attributes.description"), description) }}
{{ fields::field("Description", description) }}
{% when None %}
{{ fields::field(t!("book.attributes.description"), "-") }}
{{ fields::field("Description", "-") }}
{% endmatch %}
<h5 class="mt-50px fw-bold text-decoration-underline">{{ t!("book.show.user_details") }}</h5>
{{ fields::field(t!("book.attributes.owner"), owner.name) }}
<h5 class="mt-50px fw-bold">User Details</h5>
{{ fields::field("Owner", owner.name) }}
{% match current_holder %}
{% when Some with (current_holder) %}
{{ fields::field(t!("book.attributes.current_holder"), current_holder.name) }}
{{ fields::field("Current Holder", current_holder.name) }}
{% when None %}
{{ fields::field(t!("book.attributes.current_holder"), "-") }}
{{ fields::field("Current Holder", "-") }}
{% endmatch %}
<h5 class="mt-50px fw-bold text-decoration-underline">{{ t!("book.show.more_informations") }}</h5>
<h5 class="mt-50px fw-bold">More Informations</h5>
{% match book.comment %}
{% when Some with (comment) %}
{{ fields::field(t!("book.attributes.comment"), comment) }}
{{ fields::field("Comment", comment) }}
{% when None %}
{{ fields::field(t!("book.attributes.comment"), "-") }}
{{ fields::field("Comment", "-") }}
{% endmatch %}
</div>
{% endcall %}

View File

@ -11,40 +11,21 @@
</div>
{% endmacro %}
{% macro crud_dropdown_button(book, label, sub_path, show = true) %}
{% macro book_dropdown_button(book, show = true) %}
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ label }}
Actions
</button>
<ul class="dropdown-menu">
{% if show %}
<li><a class="dropdown-item" href="{{ router.root_path() }}{{ sub_path }}/{{ book.id }}">{{ t!("common.show") }}</a></li>
<li><a class="dropdown-item" href="/books/{{ book.id }}">Voir</a></li>
{% endif %}
<li><a class="dropdown-item" href="{{ router.root_path() }}{{ sub_path }}/{{ book.id }}/edit">{{ t!("common.edit") }}</a></li>
<li><a class="dropdown-item" href="/books/{{ book.id }}/edit">Edit</a></li>
<li>
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#deleteUserModal{{ book.id }}">{{ t!("common.delete") }}</a>
<form method="post" action="/books/{{ book.id }}/delete">
<input class="dropdown-item" type="submit" value="Delete">
</form>
</li>
</ul>
</div>
<!-- Modal -->
<div class="modal fade" id="deleteUserModal{{ book.id }}" tabindex="-1" aria-labelledby="deleteUserModal{{ book.id }}Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="exampleModalLabel">{{ t!("common.confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{{ t!("common.are_you_sure") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t!("common.close") }}</button>
<form method="post" action="{{ router.root_path() }}{{ sub_path }}/{{ book.id }}/delete" class="m-0">
<input class="btn btn-danger" type="submit" value='{{ t!("common.delete") }}'>
</form>
</div>
</div>
</div>
</div>
{% endmacro %}

View File

@ -1,43 +0,0 @@
{% macro input(name, label, value = "", type = "text", is_required = false, placeholder = "", margin_bottom = true) %}
<div {% if margin_bottom %}class="mb-3"{% endif %}>
<label for="{{ name }}" class="form-label">
{{ label }}
{% if is_required %}
<span class="text-danger">*</span>
{% endif %}
</label>
<input type="{{ type }}" value="{{ value }}" name="{{ name }}" class="form-control" placeholder="{{ placeholder }}" {% if is_required %}required{% endif %}>
</div>
{% endmacro %}
{% macro select(name, label, options, selected_value = "", is_required = false, margin_bottom = true) -%}
<div {% if margin_bottom %}class="mb-3"{% endif %}>
<label for="{{ name }}" class="form-label">
{{ label }}
{% if is_required %}
<span class="text-danger">*</span>
{% endif %}
</label>
<select name="{{ name }}" class="form-select" {% if is_required %}required{% endif %}>
{% if !is_required %}
<option></option>
{% endif %}
{% for option in options %}
{{ caller(option) }}
{% endfor %}
</select>
</div>
{%- endmacro %}
{% macro textarea(name, label, value = "", rows = 2, is_required = false, placeholder = "", margin_bottom = true) %}
<div {% if margin_bottom %}class="mb-3"{% endif %}>
<label for="{{ name }}" class="form-label">
{{ label }}
{% if is_required %}
<span class="text-danger">*</span>
{% endif %}
</label>
<textarea value="{{ value }}" name="{{ name }}" rows="{{ rows }}" class="form-control" placeholder="{{ placeholder }}" {% if is_required %}required{% endif %}></textarea>
</div>
{% endmacro %}

View File

@ -21,7 +21,7 @@
</h1>
<div>
{{ dropdown::crud_dropdown_button(book, t!("common.actions"), "books", show) }}
{{ dropdown::book_dropdown_button(book, show) }}
</div>
</div>
{% endmacro %}

View File

@ -1,17 +1 @@
{% extends "base.html" %}
{% block title %}
{{ t!("error.generic.title_tag") }}
{% endblock %}
{% block main %}
<div class="mt-4 text-center">
<h1>{{ t!("error.generic.title") }}</h1>
<div class="alert alert-danger">
{% for error in state.errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
<a href="{{ router.root_path() }}" class="mt-3 btn btn-info">{{ t!("error.error_404.button") }}</a>
</div>
{% endblock %}
Error

View File

@ -3,22 +3,14 @@
{% import "components/dropdown.html" as dropdown %}
{% import "components/cards.html" as cards %}
{% block title %}
{{ t!("book.index.title_tag") }}
{% endblock %}
{% block main %}
{% call typography::heading(t!("book.index.title")) %}
<a href="{{ router.download_csv_book_path() }}?{{ base_query }}" class="btn btn-info">
<i class="fa fa-download me-2" aria-hidden="true"></i> {{ t!("common.download") }} (csv)
</a>
{% endcall %}
{{ typography::heading("All books") }}
{% call cards::card() %}
<form method="get">
<div class="row">
<div class="col-md-3">
<label for="title" class="form-label">{{ t!("book.attributes.title") }}</label>
<label for="title" class="form-label">Name</label>
{% match query.title %}
{% when Some with (value) %}
<input type="text" name="title" value="{{ value }}" class="form-control" placeholder="Ex: La liberte ou rien">
@ -28,7 +20,7 @@
</div>
<div class="col-md-3">
<label for="authors" class="form-label">{{ t!("book.attributes.authors") }}</label>
<label for="authors" class="form-label">Authors</label>
{% match query.authors %}
{% when Some with (value) %}
@ -39,9 +31,9 @@
</div>
<div class="col-md-2">
<label for="owner_id" class="form-label">{{ t!("book.attributes.owner") }}</label>
<label for="owner_id" class="form-label">Owner</label>
<select name="owner_id" class="form-select">
<select name="owner_id" class="form-control">
<option></option>
{% match query.owner_id %}
{% when Some with (owner_id) %}
@ -61,9 +53,9 @@
</div>
<div class="col-md-2">
<label for="current_holder_id" class="form-label">{{ t!("book.attributes.current_holder") }}</label>
<label for="current_holder_id" class="form-label">Current Holder</label>
<select name="current_holder_id" class="form-select">
<select name="current_holder_id" class="form-control">
<option></option>
{% match query.current_holder_id %}
{% when Some with (current_holder_id) %}
@ -83,84 +75,67 @@
</div>
<div class="col-md-1 d-flex align-items-end">
<input type="submit" value='{{ t!("common.search") }}' class="btn btn-info w-100">
</div>
<div class="col-md-1 d-flex align-items-end">
<a href="{{ router.root_path() }}" class="btn btn-light">{{ t!("common.reset") }}</a>
<input type="submit" value="Search" class="btn btn-info btn">
</div>
</div>
</form>
{% endcall %}
{% call cards::card() %}
{% if books_with_user.is_empty() %}
<div class="d-flex flex-column align-items-center justify-content-center">
<h2>{{ t!("common.no_result") }}</h2>
<a class="btn btn-success text-white text-nowrap mt-3" href="{{ router.new_book_path() }}">
{{ t!("book.new.button_short") }}
</a>
</div>
{% else %}
<table class="table table-hover">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">{{ t!("book.attributes.title") }}</th>
<th scope="col">{{ t!("book.attributes.authors") }}</th>
<th scope="col">{{ t!("book.attributes.owner") }}</th>
<th scope="col">{{ t!("book.attributes.current_holder") }}</th>
<th scope="col">{{ t!("common.actions") }}</th>
</tr>
</thead>
<tbody>
{% for book_user in books_with_user %}
<tr class="align-middle">
<th scope="row">{{ book_user.book.id }}</th>
<td>{{ book_user.book.title }}</td>
<td>{{ book_user.book.authors }}</td>
<td>{{ book_user.owner.name }}</td>
<td>
{% match book_user.current_holder %}
{% when Some with (current_holder) %}
{{ current_holder.name }}
{% when None %}
-
{% endmatch %}
</td>
<td>
{{ dropdown::crud_dropdown_button(book_user.book, t!("common.actions"), "books") }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if total_page > 1 %}
<div class="d-flex justify-content-center mt-1">
<nav aria-label="Page navigation">
<ul class="pagination">
<li class="page-item {% if current_page <= 1 %}disabled{% endif %}">
<a class="page-link" href="{{ router.root_path() }}?{{ base_query }}page={% if current_page > 1 %}{{ current_page - 1 }}{% else %}1{% endif %}">{{ t!("common.previous") }}</a>
</li>
{% for page in 1..(total_page + 1) %}
{% if page >= current_page - 1 && page <= current_page + 1 %}
<li class="page-item {% if page == current_page %}active{% endif %}">
<a class="page-link" href="{{ router.root_path() }}?{{ base_query }}page={{ page }}">{{ page }}</a>
</li>
{% endif %}
{% endfor %}
<li class="page-item {% if current_page == total_page %}disabled{% endif %}">
<a class="page-link" href="{{ router.root_path() }}?{{ base_query }}page={{ current_page + 1 }}">{{ t!("common.next") }}</a>
</li>
</ul>
</nav>
</div>
{% endif %}
<table class="table table-hover">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Name</th>
<th scope="col">Author(s)</th>
<th scope="col">Owner</th>
<th scope="col">Current Holder</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{% for book_user in books_with_user %}
<tr>
<th scope="row">{{ book_user.book.id }}</th>
<td>{{ book_user.book.title }}</td>
<td>{{ book_user.book.authors }}</td>
<td>{{ book_user.owner.name }}</td>
<td>
{% match book_user.current_holder %}
{% when Some with (current_holder) %}
{{ current_holder.name }}
{% when None %}
-
{% endmatch %}
</td>
<td>
{{ dropdown::book_dropdown_button(book_user.book) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if total_page > 1 %}
<nav aria-label="Page navigation">
<ul class="pagination">
<li class="page-item {% if current_page <= 1 %}disabled{% endif %}">
<a class="page-link" href="/?{{ base_query }}page={% if current_page > 1 %}{{ current_page - 1 }}{% else %}1{% endif %}">Prev</a>
</li>
{% for page in 1..(total_page + 1) %}
{% if page >= current_page - 1 && page <= current_page + 1 %}
<li class="page-item {% if page == current_page %}active{% endif %}">
<a class="page-link" href="/?{{ base_query }}page={{ page }}">{{ page }}</a>
</li>
{% endif %}
{% endfor %}
<li class="page-item {% if current_page == total_page %}disabled{% endif %}">
<a class="page-link" href="/?{{ base_query }}page={{ current_page + 1 }}">Next</a>
</li>
</ul>
</nav>
{% endif %}
{% endcall %}
{% endblock %}

View File

@ -1,5 +1,5 @@
<div class="container text-center mt-4">
<div class="d-flex flex-column flex-sm-row gap-0 gap-sm-3 justify-content-center">
<p>{{ t!("footer.message") }}</p>
<p>Made with Love & Fuck a Fascist !</p>
</div>
</div>
</div>

View File

@ -1,33 +1,34 @@
<nav class="navbar navbar-expand-lg bg-body-tertiary fixed-top shadow">
<div class="container">
<a class="navbar-brand" href="{{ router.root_path() }}">
{{ t!("name") }}
<a class="navbar-brand" href="/">
BookForge
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="{{ t!("nav.toggle") }}">
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ router.root_path() }}">{{ t!("nav.books") }}</a>
<a class="nav-link" href="/">Books</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ router.index_user_path() }}">{{ t!("nav.users") }}</a>
<a class="nav-link" href="/users">Users</a>
</li>
</ul>
<div class="d-flex align-items-center gap-2 py-3">
<select id="changeTheme" class="form-select">
<option value="light">{{ t!("theme.light") }}</option>
<option value="dark">{{ t!("theme.dark") }}</option>
<option value="light">light</option>
<option value="dark">dark</option>
</select>
<a class="btn btn-success text-white text-nowrap" href="{{ router.new_book_path() }}">
{{ t!("book.new.button_short") }}
<a class="btn btn-success text-white" href="/books/new">
<i class="fa fa-download me-2"></i>
Add&nbsp;book
</a>
</div>
</div>
</div>
</nav>
</nav>

View File

@ -1,26 +0,0 @@
{% extends "base.html" %}
{% import "components/typography.html" as typography %}
{% import "components/cards.html" as cards %}
{% import "components/inputs.html" as form_helpers %}
{% block title %}
{{ t!("book.edit.title_tag") }}
{% endblock %}
{% block main %}
{{ typography::heading(t!("user.edit.title")) }}
{% call cards::card() %}
<form action="{{ router.update_user_path(&user.id) }}" method="post">
<div class="row align-items-end">
<div class="col-md-10">
{{ form_helpers::input("name", t!("user.attributes.name"), value = user.name, is_required = true, placeholder = "Ex: Kropotkine", margin_bottom = false) }}
</div>
<div class="col-md-2">
<input type="submit" value='{{ t!("user.edit.button") }}' class="btn btn-success">
</div>
</div>
</form>
{% endcall %}
{% endblock %}

View File

@ -1,76 +1,46 @@
{% extends "base.html" %}
{% import "components/typography.html" as typography %}
{% import "components/dropdown.html" as dropdown %}
{% import "components/cards.html" as cards %}
{% import "components/inputs.html" as form_helpers %}
{% block title %}
{{ t!("user.index.title_tag") }}
{% endblock %}
{% block main %}
{% call typography::heading(t!("user.index.title")) %}
<a class="btn-success btn" href="{{ router.new_user_path() }}">{{ t!("user.index.button") }}</a>
{{ typography::heading("All users") }}
{% call cards::card() %}
<table class="table table-hover">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Name</th>
<th scope="col">Owner book</th>
<th scope="col">Borrowed book</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{% for (user, book_size, borrowed_book) in user_with_books_number %}
<tr>
<th scope="row">{{ user.id }}</th>
<td>{{ user.name }}</td>
<td>{{ book_size }}</td>
<td>{{ borrowed_book }}</td>
<td>
<form method="post" action="/users/{{ user.id }}">
<input value="Delete" type="submit" class="btn btn-danger btn-sm">
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endcall %}
{% call cards::card() %}
<form method="get">
<div class="row">
<div class="col-md-3">
<label for="name" class="form-label">{{ t!("user.attributes.name") }}</label>
{% match query.name %}
{% when Some with (value) %}
<input type="text" name="name" value="{{ value }}" class="form-control" placeholder="Ex: Koprotkine">
{% when None %}
<input type="text" name="name" class="form-control" placeholder="Ex: Koprotkine">
{% endmatch %}
</div>
<form action="/users" method="post">
<label class="form-label">Name</label>
<input type="text" name="name" class="form-control">
<div class="col-md-1 d-flex align-items-end">
<input type="submit" value='{{ t!("common.search") }}' class="btn btn-info w-100">
</div>
<div class="col-md-1 d-flex align-items-end">
<a href="/users" class="btn btn-light">{{ t!("common.reset") }}</a>
</div>
</div>
<input type="submit" value="Create user" class="btn btn-success mt-3">
</form>
{% endcall %}
{% call cards::card() %}
{% if users_with_books_number.is_empty() %}
<div class="d-flex flex-column align-items-center justify-content-center">
<h2>{{ t!("common.no_result") }}</h2>
<a class="btn btn-success text-white text-nowrap mt-3" href="{{ router.new_user_path() }}">
{{ t!("user.index.button") }}
</a>
</div>
{% else %}
<table class="table table-hover">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">{{ t!("user.attributes.name") }}</th>
<th scope="col">{{ t!("user.attributes.owner_books") }}</th>
<th scope="col">{{ t!("user.attributes.borrowed_books") }}</th>
<th scope="col">{{ t!("common.actions") }}</th>
</tr>
</thead>
<tbody>
{% for user_information in users_with_books_number %}
<tr class="align-middle">
<th scope="row">{{ user_information.user.id }}</th>
<td>{{ user_information.user.name }}</td>
<td>{{ user_information.owner_book_number }}</td>
<td>{{ user_information.borrowed_book_number }}</td>
<td>
{{ dropdown::crud_dropdown_button(user_information.user, t!("common.actions"), "users", show = false) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endcall %}
{% endblock %}

View File

@ -1,25 +0,0 @@
{% extends "base.html" %}
{% import "components/typography.html" as typography %}
{% import "components/cards.html" as cards %}
{% import "components/inputs.html" as form_helpers %}
{% block title %}
{{ t!("user.new.title_tag") }}
{% endblock %}
{% block main %}
{{ typography::heading(t!("user.new.title")) }}
{% call cards::card() %}
<form action="{{ router.create_user_path() }}" method="post">
<div class="row align-items-end">
<div class="col-md-10">
{{ form_helpers::input("name", t!("user.attributes.name"), is_required = true, placeholder = "Ex: Kropotkine", margin_bottom = false) }}
</div>
<div class="col-md-2">
<input type="submit" value='{{ t!("user.new.button") }}' class="btn btn-success">
</div>
</div>
</form>
{% endcall %}
{% endblock %}