invest
All checks were successful
ci/woodpecker/push/build Pipeline was successful

This commit is contained in:
JMARyA 2025-02-23 04:37:13 +01:00
parent d44246a483
commit 197e4cfde9
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
8 changed files with 302 additions and 50 deletions

63
Cargo.lock generated
View file

@ -49,9 +49,9 @@ dependencies = [
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.95" version = "1.0.96"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4"
[[package]] [[package]]
name = "async-stream" name = "async-stream"
@ -152,7 +152,7 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
[[package]] [[package]]
name = "based" name = "based"
version = "0.1.0" 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 = [ dependencies = [
"bcrypt", "bcrypt",
"chrono", "chrono",
@ -257,9 +257,9 @@ checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.14" version = "1.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c3d1b2e905a3a7b00a6141adb0e4c0bb941d11caf55349d863942a1cc44e3c9" checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af"
dependencies = [ dependencies = [
"shlex", "shlex",
] ]
@ -1166,9 +1166,9 @@ checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.3" version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [ dependencies = [
"generic-array", "generic-array",
] ]
@ -1271,9 +1271,9 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.25" version = "0.4.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e"
[[package]] [[package]]
name = "loom" name = "loom"
@ -1345,9 +1345,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.4" version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b" checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5"
dependencies = [ dependencies = [
"adler2", "adler2",
] ]
@ -1384,9 +1384,9 @@ dependencies = [
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.13" version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dab59f8e050d5df8e4dd87d9206fb6f65a483e20ac9fda365ade4fab353196c" checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
@ -1764,9 +1764,9 @@ dependencies = [
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.8" version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f"
dependencies = [ dependencies = [
"bitflags 2.8.0", "bitflags 2.8.0",
] ]
@ -2104,18 +2104,18 @@ dependencies = [
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.217" version = "1.0.218"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.217" version = "1.0.218"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2124,9 +2124,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.138" version = "1.0.139"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@ -2567,9 +2567,9 @@ dependencies = [
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.16.0" version = "3.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91" checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"fastrand", "fastrand",
@ -2847,9 +2847,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.17.0" version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]] [[package]]
name = "ubyte" name = "ubyte"
@ -2869,6 +2869,7 @@ dependencies = [
"rocket", "rocket",
"serde", "serde",
"serde_yml", "serde_yml",
"sqlx",
"toml", "toml",
] ]
@ -2906,9 +2907,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.16" version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe"
[[package]] [[package]]
name = "unicode-normalization" name = "unicode-normalization"
@ -2962,9 +2963,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.13.1" version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced87ca4be083373936a67f8de945faa23b6b42384bd5b64434850802c6dccd0" checksum = "93d59ca99a559661b96bf898d8fce28ed87935fd2bea9f05983c1464dd6c71b1"
dependencies = [ dependencies = [
"getrandom 0.3.1", "getrandom 0.3.1",
"serde", "serde",
@ -3318,9 +3319,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.7.2" version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59690dea168f2198d1a3b0cac23b8063efcd11012f10ae4698f284808c8ef603" checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]

View file

@ -9,4 +9,5 @@ rocket = "0.5.1"
serde = { version = "1.0.210", features = ["derive"] } serde = { version = "1.0.210", features = ["derive"] }
serde_yml = "0.0.12" serde_yml = "0.0.12"
toml = "0.8.19" 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"

View file

@ -10,6 +10,11 @@ description = "Root Project"
# Project Icon # Project Icon
icon = "icon.png" icon = "icon.png"
# Associated repositories
repositories = [
"https://github.com/example/example"
]
# Project Website # Project Website
website = "https://example.com" website = "https://example.com"

9
migrations/0001_init.sql Normal file
View file

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

View file

@ -12,6 +12,7 @@ pub struct Project {
pub description: String, pub description: String,
pub icon: Option<String>, pub icon: Option<String>,
pub website: Option<String>, pub website: Option<String>,
pub repositories: Option<Vec<String>>,
pub documentation: Option<String>, pub documentation: Option<String>,
pub since: Option<String>, pub since: Option<String>,
pub contact: Option<ContactInfo>, pub contact: Option<ContactInfo>,

View file

@ -1,10 +1,97 @@
// TODO : Investment feature
// TODO : Reoccuring investments // 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 struct Investment {
pub id: i64,
pub project: String, pub project: String,
pub title: String,
pub description: String,
pub amount: f64, pub amount: f64,
pub what: String,
pub why: String,
pub when: 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<Investment> {
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/<root>/invest", data = "<form>")]
pub async fn post_new_invest(root: String, form: Form<NewInvestForm>) -> Redirect {
Investment::new_investment(
&root,
form.amount.parse().unwrap(),
&form.title,
&form.description,
)
.await;
Redirect::to(format!("/prj/{}?tab=invest", root))
} }

View file

@ -1,17 +1,25 @@
use std::path::Path; use std::path::Path;
use based::asset::AssetRoutes; use based::asset::AssetRoutes;
use based::page;
use based::request::{RequestContext, StringResponse}; use based::request::{RequestContext, StringResponse};
use based::ui::components::{Breadcrumb, Shell, Tabs}; use based::ui::components::prelude::{
use based::ui::prelude::*; ColoredMaterialIcon, MaterialIcon, Modal, ModalOpenButton, Shell, Timeline, TimelineElement,
use based::ui::primitives::flex::Row; 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::primitives::Optional;
use based::ui::{prelude::*, AttrExtendable};
use based::{get_pg, page};
use config::{get_prj, Project, ProjectConfig}; use config::{get_prj, Project, ProjectConfig};
use invest::Investment;
use maud::{PreEscaped, Render}; use maud::{PreEscaped, Render};
use rocket::fs::NamedFile; use rocket::fs::NamedFile;
use rocket::request::FromSegments; 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; use site::gen_shell;
mod config; mod config;
@ -52,19 +60,20 @@ impl<'r> FromSegments<'r> for PathSegment {
} }
} }
#[get("/prj/<path..>")] #[get("/prj/<path..>?<tab>")]
pub async fn project_page( pub async fn project_page(
path: PathSegment, path: PathSegment,
ctx: RequestContext, ctx: RequestContext,
shell: &State<Shell>, shell: &State<Shell>,
c: &State<ProjectConfig>, c: &State<ProjectConfig>,
tab: Option<&str>,
) -> Option<StringResponse> { ) -> Option<StringResponse> {
let mut prj = c.get(path.segments.first()?); let mut prj = c.get(path.segments.first()?);
for p in &path.segments[1..] { for p in &path.segments[1..] {
prj = prj.as_ref()?.sub.as_ref()?.get(p); 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/<file>")] #[get("/static/<file>")]
@ -84,7 +93,7 @@ pub async fn main_page(
let first = keys.first().unwrap(); let first = keys.first().unwrap();
let prj = c.get(*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 { } else {
// TODO : root project overview // TODO : root project overview
site::gen_site(c, ctx).await site::gen_site(c, ctx).await
@ -105,6 +114,120 @@ pub fn DetailLink(text: &str, reference: &str) -> PreEscaped<String> {
.render() .render()
} }
pub fn build_invest_timeline(investments: &[Investment]) -> PreEscaped<String> {
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<String> {
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<String> { pub fn info_tab(prj: &Project, root: &str) -> PreEscaped<String> {
Flex( Flex(
Div() Div()
@ -119,6 +242,14 @@ pub fn info_tab(prj: &Project, root: &str) -> PreEscaped<String> {
.push(Optional(prj.documentation.as_deref(), |docs| { .push(Optional(prj.documentation.as_deref(), |docs| {
DetailLink("Documentation:", 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| { .push(Optional(prj.since.as_deref(), |since| {
Text(&format!("Since: {since}")) Text(&format!("Since: {since}"))
})) }))
@ -193,6 +324,7 @@ pub async fn render_project(
prj: &Project, prj: &Project,
c: &State<ProjectConfig>, c: &State<ProjectConfig>,
mut root: String, mut root: String,
tab: &str,
) -> StringResponse { ) -> StringResponse {
let title = format!("{} - Umbrella ☂️", prj.name); let title = format!("{} - Umbrella ☂️", prj.name);
@ -214,6 +346,8 @@ pub async fn render_project(
}) })
.collect(); .collect();
let invest_tab = invest_tab(prj, &root).await;
page!( page!(
shell, shell,
ctx, ctx,
@ -230,12 +364,9 @@ pub async fn render_project(
.push( .push(
Padding( Padding(
Tabs() Tabs()
.active(tab)
.add_tab("info", Text("📜 Info").medium(), info_tab(prj, &root)) .add_tab("info", Text("📜 Info").medium(), info_tab(prj, &root))
.add_tab( .add_tab("invest", Text("💵 Invest").medium(), invest_tab)
"invest",
Text("💵 Invest").medium(),
Div().vanish().push(Text("TODO"))
)
) )
.x(ScreenValue::_6) .x(ScreenValue::_6)
) )
@ -254,9 +385,26 @@ async fn rocket() -> _ {
let shell = gen_shell(); let shell = gen_shell();
sqlx::migrate!("./migrations").run(get_pg!()).await.unwrap();
rocket::build() rocket::build()
.mount_assets() .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(conf)
.manage(shell) .manage(shell)
} }
#[post("/invest/pay?<id>")]
pub async fn pay_investment(id: i64) -> Redirect {
let prj = Investment::pay(id).await;
Redirect::to(format!("/prj/{}?tab=invest", prj))
}

View file

@ -1,6 +1,6 @@
use based::{ use based::{
request::{RequestContext, StringResponse}, request::{RequestContext, StringResponse},
ui::components::Shell, ui::components::prelude::Shell,
}; };
use maud::{html, Render}; use maud::{html, Render};