diff --git a/Cargo.lock b/Cargo.lock index 43fb984..e7a594e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,9 +49,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.95" +version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4" [[package]] name = "async-stream" @@ -152,7 +152,7 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "based" version = "0.1.0" -source = "git+https://git.hydrar.de/jmarya/based?branch=ui#f2cfcf27bbe7668caf95a99e7b8f7698e023fdec" +source = "git+https://git.hydrar.de/jmarya/based#b8ed8da199e372a704c2b457c49549e396e44e66" dependencies = [ "bcrypt", "chrono", @@ -257,9 +257,9 @@ checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" [[package]] name = "cc" -version = "1.2.14" +version = "1.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c3d1b2e905a3a7b00a6141adb0e4c0bb941d11caf55349d863942a1cc44e3c9" +checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af" dependencies = [ "shlex", ] @@ -1166,9 +1166,9 @@ checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" [[package]] name = "inout" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "generic-array", ] @@ -1271,9 +1271,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" [[package]] name = "loom" @@ -1345,9 +1345,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "miniz_oxide" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", ] @@ -1384,9 +1384,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dab59f8e050d5df8e4dd87d9206fb6f65a483e20ac9fda365ade4fab353196c" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" dependencies = [ "libc", "log", @@ -1764,9 +1764,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f" dependencies = [ "bitflags 2.8.0", ] @@ -2104,18 +2104,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.217" +version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.217" +version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" dependencies = [ "proc-macro2", "quote", @@ -2124,9 +2124,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.138" +version = "1.0.139" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" +checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6" dependencies = [ "itoa", "memchr", @@ -2567,9 +2567,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.16.0" +version = "3.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91" +checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230" dependencies = [ "cfg-if", "fastrand", @@ -2847,9 +2847,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "ubyte" @@ -2869,6 +2869,7 @@ dependencies = [ "rocket", "serde", "serde_yml", + "sqlx", "toml", ] @@ -2906,9 +2907,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" +checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" [[package]] name = "unicode-normalization" @@ -2962,9 +2963,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced87ca4be083373936a67f8de945faa23b6b42384bd5b64434850802c6dccd0" +checksum = "93d59ca99a559661b96bf898d8fce28ed87935fd2bea9f05983c1464dd6c71b1" dependencies = [ "getrandom 0.3.1", "serde", @@ -3318,9 +3319,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59690dea168f2198d1a3b0cac23b8063efcd11012f10ae4698f284808c8ef603" +checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 1acc161..339aeaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,4 +9,5 @@ rocket = "0.5.1" serde = { version = "1.0.210", features = ["derive"] } serde_yml = "0.0.12" toml = "0.8.19" -based = { git = "https://git.hydrar.de/jmarya/based", branch = "ui" } +based = { git = "https://git.hydrar.de/jmarya/based" } +sqlx = "0.8.3" diff --git a/config.toml b/config.toml index 20ba85c..23fd582 100644 --- a/config.toml +++ b/config.toml @@ -10,6 +10,11 @@ description = "Root Project" # Project Icon icon = "icon.png" +# Associated repositories +repositories = [ + "https://github.com/example/example" +] + # Project Website website = "https://example.com" diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..7808f18 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,9 @@ +CREATE TABLE investment ( + id BIGSERIAL PRIMARY KEY, + project TEXT NOT NULL, + title TEXT NOT NULL, + "description" TEXT NOT NULL, + amount DOUBLE PRECISION NOT NULL, + "when" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + payed BOOLEAN NOT NULL DEFAULT false +); diff --git a/src/config.rs b/src/config.rs index 4695df2..0fb1370 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,7 @@ pub struct Project { pub description: String, pub icon: Option, pub website: Option, + pub repositories: Option>, pub documentation: Option, pub since: Option, pub contact: Option, diff --git a/src/invest.rs b/src/invest.rs index e36ab86..25277f7 100644 --- a/src/invest.rs +++ b/src/invest.rs @@ -1,10 +1,97 @@ -// TODO : Investment feature // TODO : Reoccuring investments +use based::get_pg; +use rocket::{form::Form, post, response::Redirect, FromForm}; +use sqlx::prelude::FromRow; + +#[derive(Debug, Clone, FromRow)] pub struct Investment { + pub id: i64, pub project: String, + pub title: String, + pub description: String, pub amount: f64, - pub what: String, - pub why: String, pub when: String, + pub payed: bool, +} + +impl Investment { + pub async fn new_investment( + project: &str, + amount: f64, + title: &str, + description: &str, + ) -> Investment { + let project = project.trim_end_matches("/"); + sqlx::query_as( + "INSERT INTO investment (project, title, description, amount) + VALUES ($1, $2, $3, $4) + RETURNING *", + ) + .bind(project) + .bind(title) + .bind(description) + .bind(amount) + .fetch_one(get_pg!()) + .await + .unwrap() + } + + pub async fn pay(id: i64) -> String { + let res: (String,) = + sqlx::query_as("UPDATE investment SET payed = true WHERE id = $1 RETURNING project") + .bind(id) + .fetch_one(get_pg!()) + .await + .unwrap(); + res.0 + } + + pub async fn get_all_investments_of(prj: &str) -> Vec { + let prj = prj.trim_end_matches("/"); + sqlx::query_as("SELECT * FROM investment WHERE project = $1 ORDER BY \"when\" DESC") + .bind(prj) + .fetch_all(get_pg!()) + .await + .unwrap() + } + + pub async fn get_total_of(prj: &str) -> (f64, f64) { + let prj = prj.trim_end_matches("/"); + let payed: Result<(f64,), sqlx::Error> = sqlx::query_as( + "SELECT SUM(amount) FROM investment WHERE project = $1 AND payed = true", + ) + .bind(prj) + .fetch_one(get_pg!()) + .await; + let total: Result<(f64,), sqlx::Error> = + sqlx::query_as("SELECT SUM(amount) FROM investment WHERE project = $1") + .bind(prj) + .fetch_one(get_pg!()) + .await; + if let (Ok(payed), Ok(total)) = (payed, total) { + (payed.0, total.0) + } else { + (0.00, 0.00) + } + } +} + +#[derive(FromForm)] +pub struct NewInvestForm { + title: String, + amount: String, + description: String, +} + +#[post("/prj//invest", data = "
")] +pub async fn post_new_invest(root: String, form: Form) -> Redirect { + Investment::new_investment( + &root, + form.amount.parse().unwrap(), + &form.title, + &form.description, + ) + .await; + Redirect::to(format!("/prj/{}?tab=invest", root)) } diff --git a/src/main.rs b/src/main.rs index 70d75d0..2ee662d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,25 @@ use std::path::Path; use based::asset::AssetRoutes; -use based::page; use based::request::{RequestContext, StringResponse}; -use based::ui::components::{Breadcrumb, Shell, Tabs}; -use based::ui::prelude::*; -use based::ui::primitives::flex::Row; +use based::ui::components::prelude::{ + ColoredMaterialIcon, MaterialIcon, Modal, ModalOpenButton, Shell, Timeline, TimelineElement, + Tooltip, +}; +use based::ui::components::{Breadcrumb, Card, ProgressBar, Tabs}; +use based::ui::primitives::div::Center; +use based::ui::primitives::flex::{Column, Row}; +use based::ui::primitives::list::{CheckIcon, CheckIconRounded, CheckIconRoundedGray}; use based::ui::primitives::Optional; +use based::ui::{prelude::*, AttrExtendable}; +use based::{get_pg, page}; use config::{get_prj, Project, ProjectConfig}; +use invest::Investment; use maud::{PreEscaped, Render}; use rocket::fs::NamedFile; use rocket::request::FromSegments; -use rocket::{get, launch, routes, State}; +use rocket::response::Redirect; +use rocket::{get, launch, post, routes, State}; use site::gen_shell; mod config; @@ -52,19 +60,20 @@ impl<'r> FromSegments<'r> for PathSegment { } } -#[get("/prj/")] +#[get("/prj/?")] pub async fn project_page( path: PathSegment, ctx: RequestContext, shell: &State, c: &State, + tab: Option<&str>, ) -> Option { let mut prj = c.get(path.segments.first()?); for p in &path.segments[1..] { prj = prj.as_ref()?.sub.as_ref()?.get(p); } - Some(render_project(shell, ctx, prj?, c, path.to_str()).await) + Some(render_project(shell, ctx, prj?, c, path.to_str(), tab.unwrap_or_default()).await) } #[get("/static/")] @@ -84,7 +93,7 @@ pub async fn main_page( let first = keys.first().unwrap(); let prj = c.get(*first).unwrap(); - render_project(shell, ctx, prj, c, format!("{first}/")).await + render_project(shell, ctx, prj, c, format!("{first}/"), "").await } else { // TODO : root project overview site::gen_site(c, ctx).await @@ -105,6 +114,120 @@ pub fn DetailLink(text: &str, reference: &str) -> PreEscaped { .render() } +pub fn build_invest_timeline(investments: &[Investment]) -> PreEscaped { + let mut t = Timeline(); + + for i in investments { + t = t.add_element(TimelineElement::new( + Text(&i.when), + Row(vec![ + Text(&i.title).render(), + Text(&format!("{:.2} $", i.amount)).render(), + if i.payed { + Tooltip(CheckIconRounded(), Text("Payed")).render() + } else { + CheckIconRoundedGray() + }, + ]) + .gap(ScreenValue::_2) + .items_center(), + Column(vec![ + Text(&i.description).render(), + if !i.payed { + Form::new(&format!("/invest/pay?id={}", i.id)) + .add_input(FormSubmitButton(Text("Pay"))) + .render() + } else { + Nothing() + }, + ]) + .gap(ScreenValue::_2), + )) + } + + t.render() +} + +pub async fn invest_tab(prj: &Project, root: &str) -> PreEscaped { + let (add_invest_id, add_invest_modal) = Modal( + "Add Investment", + Form::new(&format!("/prj/{root}invest")) + .id("new_invest") + .add_input(TextInput("title").required().placeholder("Title")) + .add_input( + TextInput("amount") + .icon(MaterialIcon("attach_money")) + .required() + .placeholder("Amount") + .pattern(r#"^\d+(\.\d+)?$"#), + ) + .add_input( + TextArea() + .name("description") + .required() + .placeholder("Description"), + ), + |_| { + Center(Context( + FormSubmitButton("Create").add_attr("form", "new_invest"), + )) + }, + ); + + let investments = Investment::get_all_investments_of(root).await; + + let t = build_invest_timeline(&investments); + + let inv = Investment::get_total_of(root).await; + + let percentage = inv.0 / inv.1; + let percentage = (percentage * 100.0) as u8; + + Column(vec![ + add_invest_modal, + Margin( + Row(vec![ + Margin(Width( + ScreenValue::full, + Row(vec![ + ColoredMaterialIcon("paid", Green::_600), + Text(&format!( + "Total Investment: {:.2} $ / {:.2} $", + inv.0, inv.1 + )) + .color(&Green::_600) + .render(), + Width( + ScreenValue::full, + Margin(ProgressBar(percentage, true, Green::_600)).x(ScreenValue::_12), + ) + .render(), + ]) + .gap(ScreenValue::_2) + .full_center(), + )) + .x(ScreenValue::auto) + .render(), + ModalOpenButton( + &add_invest_id, + Padding( + Rounded(Background(MaterialIcon("add")).color(Blue::_600)) + .size(Size::Medium), + ) + .x(ScreenValue::_2) + .top(ScreenValue::_2), + ) + .render(), + ]) + .justify(Justify::Between), + ) + .left(ScreenValue::_4) + .render(), + t, + ]) + .render() +} + pub fn info_tab(prj: &Project, root: &str) -> PreEscaped { Flex( Div() @@ -119,6 +242,14 @@ pub fn info_tab(prj: &Project, root: &str) -> PreEscaped { .push(Optional(prj.documentation.as_deref(), |docs| { DetailLink("Documentation:", docs) })) + .push(Optional(prj.repositories.as_deref(), |repos| { + Column(vec![ + Text("Repositories:").xl().semibold().render(), + UnorderedList() + .push_for_each(repos, |repo: &_| Link(repo, Text(repo))) + .render(), + ]) + })) .push(Optional(prj.since.as_deref(), |since| { Text(&format!("Since: {since}")) })) @@ -193,6 +324,7 @@ pub async fn render_project( prj: &Project, c: &State, mut root: String, + tab: &str, ) -> StringResponse { let title = format!("{} - Umbrella ☂️", prj.name); @@ -214,6 +346,8 @@ pub async fn render_project( }) .collect(); + let invest_tab = invest_tab(prj, &root).await; + page!( shell, ctx, @@ -230,12 +364,9 @@ pub async fn render_project( .push( Padding( Tabs() + .active(tab) .add_tab("info", Text("📜 Info").medium(), info_tab(prj, &root)) - .add_tab( - "invest", - Text("💵 Invest").medium(), - Div().vanish().push(Text("TODO")) - ) + .add_tab("invest", Text("💵 Invest").medium(), invest_tab) ) .x(ScreenValue::_6) ) @@ -254,9 +385,26 @@ async fn rocket() -> _ { let shell = gen_shell(); + sqlx::migrate!("./migrations").run(get_pg!()).await.unwrap(); + rocket::build() .mount_assets() - .mount("/", routes![main_page, icon_res, project_page]) + .mount( + "/", + routes![ + main_page, + icon_res, + project_page, + invest::post_new_invest, + pay_investment + ], + ) .manage(conf) .manage(shell) } + +#[post("/invest/pay?")] +pub async fn pay_investment(id: i64) -> Redirect { + let prj = Investment::pay(id).await; + Redirect::to(format!("/prj/{}?tab=invest", prj)) +} diff --git a/src/site.rs b/src/site.rs index 8fc2d1e..8bfda2b 100644 --- a/src/site.rs +++ b/src/site.rs @@ -1,6 +1,6 @@ use based::{ request::{RequestContext, StringResponse}, - ui::components::Shell, + ui::components::prelude::Shell, }; use maud::{html, Render};