Added web ui with templates

This commit is contained in:
Manuel Forcén Muñoz 2024-02-19 23:51:18 +01:00
parent d2cb5b3031
commit 90b02eef79
24 changed files with 1403 additions and 78 deletions

328
Cargo.lock generated
View file

@ -212,6 +212,16 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bstr"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "542f33a8835a0884b006a0c3df3dadd99c0c3f296ed26c2fdc8028e01ad6230c"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.14.0"
@ -260,6 +270,28 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "chrono-tz"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e"
dependencies = [
"chrono",
"chrono-tz-build",
"phf",
]
[[package]]
name = "chrono-tz-build"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f"
dependencies = [
"parse-zoneinfo",
"phf",
"phf_codegen",
]
[[package]]
name = "const-oid"
version = "0.9.5"
@ -296,6 +328,25 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crossbeam-deque"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
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.8"
@ -308,12 +359,9 @@ dependencies = [
[[package]]
name = "crossbeam-utils"
version = "0.8.16"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294"
dependencies = [
"cfg-if",
]
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
[[package]]
name = "crypto-common"
@ -336,6 +384,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deunicode"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae2a35373c5c74340b79ae6780b498b2b183915ec5dacf263aac5a099bf485a"
[[package]]
name = "digest"
version = "0.10.7"
@ -561,6 +615,30 @@ version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0"
[[package]]
name = "globset"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1"
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.14.2"
@ -686,6 +764,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]]
name = "hyper"
version = "0.14.27"
@ -742,6 +829,22 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "ignore"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "indexmap"
version = "2.1.0"
@ -990,6 +1093,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "parse-zoneinfo"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41"
dependencies = [
"regex",
]
[[package]]
name = "paste"
version = "1.0.14"
@ -1011,6 +1123,89 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
[[package]]
name = "pest"
version = "2.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219c0dcc30b6a27553f9cc242972b67f75b60eb0db71f0b5462f38b058c41546"
dependencies = [
"memchr",
"thiserror",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e1288dbd7786462961e69bfd4df7848c1e37e8b74303dbdab82c3a9cdd2809"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1381c29a877c6d34b8c176e734f35d7f7f5b3adaefe940cb4d1bb7af94678e2e"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.39",
]
[[package]]
name = "pest_meta"
version = "2.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0934d6907f148c22a3acbda520c7eed243ad7487a30f51f6ce52b58b7077a8a"
dependencies = [
"once_cell",
"pest",
"sha2",
]
[[package]]
name = "phf"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_shared"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.3"
@ -1213,6 +1408,15 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
[[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 = "scopeguard"
version = "1.2.0"
@ -1313,6 +1517,12 @@ dependencies = [
"rand_core",
]
[[package]]
name = "siphasher"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "slab"
version = "0.4.9"
@ -1322,6 +1532,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "slug"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bd94acec9c8da640005f8e135a39fc0372e74535e6b368b7a04b875f784c8c4"
dependencies = [
"deunicode",
"wasm-bindgen",
]
[[package]]
name = "smallvec"
version = "1.11.2"
@ -1641,6 +1861,28 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "tera"
version = "1.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8"
dependencies = [
"chrono",
"chrono-tz",
"globwalk",
"humansize",
"lazy_static",
"percent-encoding",
"pest",
"pest_derive",
"rand",
"regex",
"serde",
"serde_json",
"slug",
"unic-segment",
]
[[package]]
name = "thiserror"
version = "1.0.50"
@ -1789,6 +2031,62 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "ucd-trie"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9"
[[package]]
name = "unic-char-property"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
dependencies = [
"unic-char-range",
]
[[package]]
name = "unic-char-range"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
[[package]]
name = "unic-common"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
[[package]]
name = "unic-segment"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23"
dependencies = [
"unic-ucd-segment",
]
[[package]]
name = "unic-ucd-segment"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700"
dependencies = [
"unic-char-property",
"unic-char-range",
"unic-ucd-version",
]
[[package]]
name = "unic-ucd-version"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
dependencies = [
"unic-common",
]
[[package]]
name = "unicode-bidi"
version = "0.3.13"
@ -1845,6 +2143,16 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "walkdir"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
@ -1925,6 +2233,7 @@ dependencies = [
"serde",
"serde_json",
"sqlx",
"tera",
"tokio",
]
@ -1950,6 +2259,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596"
dependencies = [
"winapi",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"

3
base.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

14
build.rs Normal file
View file

@ -0,0 +1,14 @@
use std::env::current_dir;
use std::process::Command;
fn main() {
Command::new("npx")
.args(&["tailwindcss", "-i", "base.css", "-o"])
.arg(&format!(
"{}/static/styles.css",
current_dir().unwrap().into_os_string().to_str().unwrap()
))
.status()
.unwrap();
println!("cargo:rerun-if-changed=templates/*")
}

View file

@ -195,7 +195,7 @@ impl AccountSnapshot {
}
}
#[derive(FromRow, Serialize, Deserialize)]
#[derive(FromRow, Serialize, Deserialize, Debug)]
pub struct Account {
account_id: i32,
user: i32,

591
static/styles.css Normal file
View file

@ -0,0 +1,591 @@
/*
! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com
*/
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
*/
*,
::before,
::after {
box-sizing: border-box;
/* 1 */
border-width: 0;
/* 2 */
border-style: solid;
/* 2 */
border-color: #e5e7eb;
/* 2 */
}
::before,
::after {
--tw-content: '';
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS
*/
html,
:host {
line-height: 1.5;
/* 1 */
-webkit-text-size-adjust: 100%;
/* 2 */
-moz-tab-size: 4;
/* 3 */
-o-tab-size: 4;
tab-size: 4;
/* 3 */
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */
font-feature-settings: normal;
/* 5 */
font-variation-settings: normal;
/* 6 */
-webkit-tap-highlight-color: transparent;
/* 7 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
margin: 0;
/* 1 */
line-height: inherit;
/* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0;
/* 1 */
color: inherit;
/* 2 */
border-top-width: 1px;
/* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font-family by default.
2. Use the user's configured `mono` font-feature-settings by default.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */
font-feature-settings: normal;
/* 2 */
font-variation-settings: normal;
/* 3 */
font-size: 1em;
/* 4 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0;
/* 1 */
border-color: inherit;
/* 2 */
border-collapse: collapse;
/* 3 */
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
/* 1 */
font-feature-settings: inherit;
/* 1 */
font-variation-settings: inherit;
/* 1 */
font-size: 100%;
/* 1 */
font-weight: inherit;
/* 1 */
line-height: inherit;
/* 1 */
color: inherit;
/* 1 */
margin: 0;
/* 2 */
padding: 0;
/* 3 */
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
/* 1 */
background-color: transparent;
/* 2 */
background-image: none;
/* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield;
/* 1 */
outline-offset: -2px;
/* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button;
/* 1 */
font: inherit;
/* 2 */
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
/*
Reset default styling for dialogs.
*/
dialog {
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to the user's configured gray 400 color.
*/
input::-moz-placeholder, textarea::-moz-placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
input::placeholder,
textarea::placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
/*
Set the default cursor for buttons.
*/
button,
[role="button"] {
cursor: pointer;
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
/* 1 */
vertical-align: middle;
/* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden] {
display: none;
}
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
::backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
.block {
display: block;
}
.flex {
display: flex;
}
.table {
display: table;
}
.h-full {
height: 100%;
}
.flex-grow {
flex-grow: 1;
}
.grow {
flex-grow: 1;
}
.flex-col {
flex-direction: column;
}
.border {
border-width: 1px;
}
.bg-stone-300 {
--tw-bg-opacity: 1;
background-color: rgb(214 211 209 / var(--tw-bg-opacity));
}
.p-4 {
padding: 1rem;
}
.hover\:bg-stone-400:hover {
--tw-bg-opacity: 1;
background-color: rgb(168 162 158 / var(--tw-bg-opacity));
}

9
tailwind.config.js Normal file
View file

@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./templates/**.html"],
theme: {
extend: {},
},
plugins: [],
}

25
templates/accounts.html Normal file
View file

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Account {{account.account_name}}{% endblock title %}
{% block body %}
<div>{{account.account_name}}</div>
<div>
<table>
<tr>
<th>Descripción</th>
<th>Fecha</th>
<th>Cantidad</th>
<th>Categoría</th>
</tr>
{% for tx in transactions %}
<tr>
<td>{{tx.description}}</td>
<td>{{tx.transaction_timestamp}}</td>
<td>{{tx.amount/100}}</td>
<td>{{tx.category}}</td>
</tr>
{% endfor %}
</table>
<p>Cargados {{n_txs}} movimientos</p>
</div>
{% endblock body %}

38
templates/base.html Normal file
View file

@ -0,0 +1,38 @@
<!doctype html>
<html>
<head>
<title>{% block title %}{% endblock title %}</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/static/styles.css">
<style>
html, body {
height: 100%;
margin: 0;
background:
}
.sidebar {
max-width: 12rem;
flex-grow: 1;
}
.sidebar a {
padding: 0.5rem;
border-radius: 3px;
}
</style>
</head>
<body>
<div class="flex h-full">
<aside class="sidebar bg-stone-300 p-4 flex flex-col">
<a class="hover:bg-stone-400" href="/">Inicio</a>
<a class="hover:bg-stone-400" href="/rules">Reglas</a>
</aside>
<div class="p-4 grow">
{% block body %}
{% endblock body %}
</div>
</div>
</body>
</html>

7
templates/index.html Normal file
View file

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block title %}Index{% endblock title %}
{% block body %}
{% for account in accounts %}
<a href="/accounts/id/{{account.account_id}}">{{account.account_name}}({{account.account_id}})</a>
{% endfor %}
{% endblock body %}

23
templates/rules_list.html Normal file
View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Rules{% endblock title %}
{% block body %}
<div>
<a href="/rules/new">New</a>
</div>
<table>
<thead>
<tr>
<th>Categoría</th>
<th>Regla</th>
</tr>
</thead>
<tbody>
{% for rule in rules %}
<tr>
<td>{{rule.category}}</td>
<td>{{rule.regex}}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock body %}

36
templates/rules_new.html Normal file
View file

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Create rule{% endblock title %}
{% block body %}
<form action="/rules/new" method="post" class="flex flex-col">
<label class="grow">
Description
<input type="text" name="description" />
</label>
<label class="grow">
Regex
<input type="text" name="regex" />
</label>
<label class="grow">
Category
<select name="category">
<option></option>
{% for cat in categories %}
<option value={{cat.category_id}}>{{cat.name}}</option>
{% endfor %}
</select>
</label>
<button type="submit">Submit</button>
</form>
<style>
label {
width: 100%;
}
input {
width: 100%;
border: 1px solid black;
border-radius: 3px;
padding: 0.25rem;
margin: 0.25rem;
}
{% endblock body %}

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Rule created</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="3;url=.." />
</head>
<body>
Rule created successfully
</body>
</html>

View file

@ -10,7 +10,8 @@ serde = { workspace = true, features = ["derive"] }
chrono = { workspace = true, features = ["serde"] }
sqlx = { workspace = true, features = ["runtime-tokio", "sqlite", "chrono"]}
tokio = { version = "1", features = ["full"] }
axum = { version = "0.6.20", features = ["macros", "headers"] }
axum = { version = "0.6.20", features = ["macros", "headers", "form"] }
hyper = "0.14.27"
serde_json = "1"
accounters = { path = ".." }
tera = "1.19.1"

View file

@ -9,6 +9,7 @@ use axum::{
Router,
};
use hyper::StatusCode;
use tera::Tera;
mod routes;
mod users;
@ -19,38 +20,56 @@ const DB_URL: &str = "sqlite://sqlite.db";
async fn main() {
let db = accounters::create_db(DB_URL).await.unwrap();
let state = AppState { db: Arc::new(db) };
let mut tmpls = Tera::new("templates/*").unwrap();
tmpls.autoescape_on(vec!["html"]);
let state = AppState {
db: Arc::new(db),
tmpls: Arc::new(tmpls),
};
let app = Router::new()
.route("/", get(index))
.nest(
"/",
Router::new()
.route("/", get(routes::ui::index))
.route("/accounts/id/:id", get(routes::ui::account))
.route("/rules", get(routes::ui::rules::list))
.route("/rules/new", get(routes::ui::rules::new_view))
.route("/rules/new", post(routes::ui::rules::new_action))
.nest(
"/static",
Router::new().route("/styles.css", get(routes::static_routes::styles)),
),
)
.nest(
"/api/v1",
Router::new()
.route("/user", post(routes::create_user))
.route("/login", post(routes::login))
.route("/accounts", post(routes::accounts::account_create))
.route("/accounts", get(routes::accounts::account_list))
.route("/accounts/id/:id", get(routes::accounts::account_get))
.route("/user", post(routes::api::create_user))
.route("/login", post(routes::api::login))
.route("/accounts", post(routes::api::accounts::account_create))
.route("/accounts", get(routes::api::accounts::account_list))
.route("/accounts/id/:id", get(routes::api::accounts::account_get))
.route(
"/accounts/id/:id/transaction",
post(routes::transactions::create),
post(routes::api::transactions::create),
)
.route(
"/accounts/id/:id/transaction",
get(routes::transactions::list),
get(routes::api::transactions::list),
)
.route(
"/accounts/id/:id/update",
post(routes::accounts::snapshot_update),
post(routes::api::accounts::snapshot_update),
)
.route(
"/accounts/id/:id/recategorize",
post(routes::accounts::recategorize),
post(routes::api::accounts::recategorize),
)
.route("/categories", post(routes::categories::create))
.route("/categories", get(routes::categories::list))
.route("/rules", post(routes::rules::create))
.route("/rules", get(routes::rules::list)),
.route("/categories", post(routes::api::categories::create))
.route("/categories", get(routes::api::categories::list))
.route("/rules", post(routes::api::rules::create))
.route("/rules", get(routes::api::rules::list)),
)
.with_state(state);
@ -64,6 +83,7 @@ async fn main() {
#[derive(Clone)]
pub struct AppState {
db: Arc<SqlitePool>,
tmpls: Arc<Tera>,
}
impl FromRef<AppState> for Arc<SqlitePool> {
@ -72,6 +92,12 @@ impl FromRef<AppState> for Arc<SqlitePool> {
}
}
impl FromRef<AppState> for Arc<Tera> {
fn from_ref(state: &AppState) -> Arc<Tera> {
state.tmpls.clone()
}
}
async fn index() -> (StatusCode, String) {
(StatusCode::OK, String::from("Hello, World!"))
}

View file

@ -1,43 +1,3 @@
use std::sync::Arc;
use axum::extract::{Json, State};
use hyper::StatusCode;
use serde::Deserialize;
use sqlx::SqlitePool;
use accounters::models::users::User;
pub mod accounts;
pub mod categories;
pub mod rules;
pub mod transactions;
#[derive(Deserialize)]
pub struct CreateUserRequest {
user: String,
pass: String,
}
pub async fn create_user(
State(db): State<Arc<SqlitePool>>,
Json(user_info): Json<CreateUserRequest>,
) -> (StatusCode, String) {
let exec = User::create_user(db.as_ref(), &user_info.user, &user_info.pass).await;
match exec {
Ok(e) => (StatusCode::OK, format!("{}", e.get_id())),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")),
}
}
pub async fn login(
State(db): State<Arc<SqlitePool>>,
Json(user_info): Json<CreateUserRequest>,
) -> (StatusCode, String) {
let user = User::get_user(db.as_ref(), &user_info.user).await.unwrap();
if user.check_pass(&user_info.pass) {
(StatusCode::OK, format!("{}", user.get_id()))
} else {
(StatusCode::UNAUTHORIZED, String::new())
}
}
pub mod api;
pub mod static_routes;
pub mod ui;

View file

@ -0,0 +1,43 @@
use std::sync::Arc;
use axum::extract::{Json, State};
use hyper::StatusCode;
use serde::Deserialize;
use sqlx::SqlitePool;
use accounters::models::users::User;
pub mod accounts;
pub mod categories;
pub mod rules;
pub mod transactions;
#[derive(Deserialize)]
pub struct CreateUserRequest {
user: String,
pass: String,
}
pub async fn create_user(
State(db): State<Arc<SqlitePool>>,
Json(user_info): Json<CreateUserRequest>,
) -> (StatusCode, String) {
let exec = User::create_user(db.as_ref(), &user_info.user, &user_info.pass).await;
match exec {
Ok(e) => (StatusCode::OK, format!("{}", e.get_id())),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")),
}
}
pub async fn login(
State(db): State<Arc<SqlitePool>>,
Json(user_info): Json<CreateUserRequest>,
) -> (StatusCode, String) {
let user = User::get_user(db.as_ref(), &user_info.user).await.unwrap();
if user.check_pass(&user_info.pass) {
(StatusCode::OK, format!("{}", user.get_id()))
} else {
(StatusCode::UNAUTHORIZED, String::new())
}
}

View file

@ -0,0 +1,12 @@
use std::fs;
use axum::response::IntoResponse;
use hyper::{header::CONTENT_TYPE, StatusCode};
pub async fn styles() -> impl IntoResponse {
(
StatusCode::OK,
[(CONTENT_TYPE, "text/css")],
fs::read_to_string("static/styles.css").unwrap(),
)
}

102
webserver/src/routes/ui.rs Normal file
View file

@ -0,0 +1,102 @@
use std::sync::Arc;
use axum::{
extract::{Path, Query, State},
response::IntoResponse,
};
use hyper::{header::CONTENT_TYPE, StatusCode};
use serde::Deserialize;
use sqlx::SqlitePool;
use tera::{Context, Tera};
use crate::users::UserToken;
use accounters::models::{Account, Transaction};
pub mod rules;
pub async fn index(
State(db): State<Arc<SqlitePool>>,
State(tmpls): State<Arc<Tera>>,
uid: UserToken,
) -> impl IntoResponse {
let mut ctx = Context::new();
let accounts = Account::list(db.as_ref(), uid.user_id).await.unwrap();
ctx.insert("accounts", &accounts);
match tmpls.render("index.html", &ctx) {
Ok(out) => (
StatusCode::OK,
[(CONTENT_TYPE, "text/html;charset=utf-8")],
out,
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
[(CONTENT_TYPE, "text/plain")],
format!("{e}"),
),
}
}
#[derive(Deserialize)]
pub struct AccountViewParams {
movements: Option<i32>,
}
pub async fn account(
State(db): State<Arc<SqlitePool>>,
State(tmpls): State<Arc<Tera>>,
uid: UserToken,
Path(account_id): Path<i32>,
Query(AccountViewParams { movements }): Query<AccountViewParams>,
) -> impl IntoResponse {
let mut ctx = Context::new();
let account = match Account::get_by_id(db.as_ref(), account_id).await {
Ok(a) => a,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
[(CONTENT_TYPE, "text/plain")],
format!("{e}"),
);
}
};
if account.get_user() != uid.user_id {
return (
StatusCode::UNAUTHORIZED,
[(CONTENT_TYPE, "text/plain")],
String::from("You cannot access this resource"),
);
}
let txs = match Transaction::list(
db.as_ref(),
account.get_id(),
movements.unwrap_or(10),
0,
false,
)
.await
{
Ok(t) => t,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
[(CONTENT_TYPE, "text/plain")],
format!("Error at loading transactions: {e}"),
);
}
};
ctx.insert("account", &account);
ctx.insert("transactions", &txs);
ctx.insert("n_txs", &txs.len());
(
StatusCode::OK,
[(CONTENT_TYPE, "text/html;charset=utf-8")],
tmpls.render("accounts.html", &ctx).unwrap(),
)
}

View file

@ -0,0 +1,84 @@
use std::sync::Arc;
use accounters::models::{categories::Category, rules::Rule};
use axum::{
extract::{Form, State},
response::IntoResponse,
};
use hyper::{header::CONTENT_TYPE, StatusCode};
use serde::Deserialize;
use sqlx::SqlitePool;
use tera::{Context, Tera};
use crate::users::UserToken;
pub async fn list(
State(db): State<Arc<SqlitePool>>,
State(tmpls): State<Arc<Tera>>,
uid: UserToken,
) -> impl IntoResponse {
let rules = match Rule::list_by_user(db.as_ref(), uid.user_id).await {
Ok(r) => r,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
[(CONTENT_TYPE, "text/plain")],
format!("{e:?}"),
);
}
};
let mut ctx = Context::new();
ctx.insert("rules", &rules);
(
StatusCode::OK,
[(CONTENT_TYPE, "text/html;charset=utf-8")],
tmpls.render("rules_list.html", &ctx).unwrap(),
)
}
pub async fn new_view(
State(db): State<Arc<SqlitePool>>,
State(tmpls): State<Arc<Tera>>,
uid: UserToken,
) -> impl IntoResponse {
let categories = Category::list(db.as_ref()).await.unwrap();
let mut ctx = Context::new();
ctx.insert("categories", &categories);
(
StatusCode::OK,
[(CONTENT_TYPE, "text/html;charset=utf-8")],
tmpls.render("rules_new.html", &ctx).unwrap(),
)
}
#[derive(Deserialize)]
pub struct NewRuleParams {
pub description: String,
pub regex: String,
pub category: i32,
}
pub async fn new_action(
State(db): State<Arc<SqlitePool>>,
State(tmpls): State<Arc<Tera>>,
uid: UserToken,
Form(params): Form<NewRuleParams>,
) -> impl IntoResponse {
match Rule::new(db.as_ref(), uid.user_id, params.regex, params.category).await {
Ok(_) => (
StatusCode::OK,
[(CONTENT_TYPE, "text/html;charset=utf-8")],
tmpls
.render("rules_new_success.html", &Context::new())
.unwrap(),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
[(CONTENT_TYPE, "text/plain;charset=utf-8")],
format!("{e}"),
),
}
}

View file

@ -1,12 +1,18 @@
use std::sync::Arc;
use accounters::models::users::User;
use axum::{
async_trait,
extract::FromRequestParts,
headers::authorization::Bearer,
extract::{FromRef, FromRequestParts},
headers::authorization::{Basic, Bearer},
headers::Authorization,
http::request::Parts,
response::{IntoResponse, Redirect},
RequestPartsExt, TypedHeader,
};
use hyper::StatusCode;
use crate::AppState;
pub struct AuthRedirect;
@ -23,20 +29,35 @@ pub struct UserToken {
#[async_trait]
impl<S> FromRequestParts<S> for UserToken
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = AuthRedirect;
type Rejection = (StatusCode, [(&'static str, &'static str); 1]);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let auth = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|e| panic!("Could not get cookies: {e}"))
.unwrap();
let ut = UserToken {
user_id: auth.0 .0.token().parse().unwrap(),
};
Ok(ut)
match parts.extract::<TypedHeader<Authorization<Bearer>>>().await {
Ok(auth) => Ok(UserToken {
user_id: auth.0 .0.token().parse().unwrap(),
}),
Err(_) => match parts.extract::<TypedHeader<Authorization<Basic>>>().await {
Ok(auth) => {
let state = AppState::from_ref(state);
let user = User::get_user(state.db.as_ref(), auth.username())
.await
.unwrap();
if user.check_pass(auth.password()) {
Ok(UserToken {
user_id: user.get_id(),
})
} else {
Err((StatusCode::UNAUTHORIZED, [("", "")]))
}
}
Err(_) => Err((
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", "Basic realm=\"Access\"")],
)),
},
}
}
}