Implement basic page navigation w/ mock data

Obviously, we'll replace the mock `Database` with a Firefly III API
client, but this is here for now to support the UI interactions.
bugfix/ci-buildah
Dustin 2025-03-08 11:16:35 -06:00
parent 837caecc3a
commit b55fb893e2
7 changed files with 2543 additions and 1 deletions

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"overrides": [
{
"files": "*.html.tera",
"options": {
"parser": "html"
}
}
]
}

2324
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,3 +10,6 @@ license = "MIT OR Apache-2.0"
keywords = ["personal-finance", "receipts"] keywords = ["personal-finance", "receipts"]
[dependencies] [dependencies]
rocket = { version = "0.5.1", default-features = false, features = ["json"] }
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
serde = { version = "1.0.218", default-features = false, features = ["derive"] }

View File

@ -1 +1,86 @@
fn main() {} use std::collections::HashMap;
use std::sync::LazyLock;
use rocket::form::Form;
use rocket::fs::{FileServer, TempFile};
use rocket::http::Status;
use rocket::response::Redirect;
use rocket::serde::Serialize;
use rocket_dyn_templates::{context, Template};
#[derive(Serialize)]
struct Transaction {
id: u32,
amount: f64,
description: String,
date: String,
}
struct Database {
transactions: HashMap<i32, Transaction>,
}
#[derive(rocket::FromForm)]
struct TransactionPostData<'r> {
amount: f32,
notes: String,
photo: Vec<TempFile<'r>>,
}
static DB: LazyLock<Database> = LazyLock::new(|| {
let mut transactions = HashMap::new();
transactions.insert(
5411,
Transaction {
id: 5411,
amount: 140.38,
description: "THE HOME DEPOT #2218".into(),
date: "March 2nd, 2025".into(),
},
);
Database { transactions }
});
#[rocket::get("/")]
async fn index() -> Redirect {
Redirect::to(rocket::uri!(transaction_list()))
}
#[rocket::get("/transactions")]
async fn transaction_list() -> Template {
let transactions: Vec<_> = DB.transactions.values().collect();
Template::render("transaction-list", context! {
transactions: transactions,
})
}
#[rocket::get("/transactions/<id>")]
async fn get_transaction(id: i32) -> Option<Template> {
let txn = DB.transactions.get(&id)?;
Some(Template::render("transaction", txn))
}
#[rocket::post("/transactions/<id>", data = "<form>")]
async fn update_transaction(
id: f32,
form: Form<TransactionPostData<'_>>,
) -> (Status, &'static str) {
println!("{} {} {}", id, form.amount, form.photo.len());
(Status::ImATeapot, "")
}
#[rocket::launch]
async fn rocket() -> _ {
rocket::build()
.mount(
"/",
rocket::routes![
index,
transaction_list,
get_transaction,
update_transaction
],
)
.mount("/static", FileServer::from("js/dist"))
.attach(Template::fairing())
}

24
templates/base.html.tera Normal file
View File

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="utf-8" />
<link
rel="icon"
href="/static/icons/icon-192.png"
sizes="192x192"
type="image/png"
/>
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png" />
<link rel="stylesheet" href="/static/common.css" />
<script src="/static/common.js"></script>
{% block head %}{% endblock -%}
</head>
<body>
<div id="page-loading">
<div>Loading ...</div>
</div>
<main class="container">{% block main %}{% endblock %}</main>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,28 @@
{% extends "base" %}
{% block head %}
<title>Transactions</title>
{% endblock %}
{% block main %}
<h1>Transactions</h1>
<p>
These transactions have not been reviewed and do not have attached receipts.
</p>
<table>
<tbody>
<tr>
<th scope="col">Description</th>
<th scope="col">Date</th>
<th scope="col">Amount</th>
</tr>
{% for txn in transactions -%}
<tr>
<td>
<a href="/transactions/{{ txn.id }}">{{ txn.description }}</a>
</td>
<td>{{ txn.date }}</td>
<td>${{ txn.amount }}</td>
</tr>
{% endfor -%}
</tbody>
</table>
{% endblock %}

View File

@ -0,0 +1,68 @@
{% extends "base" %}
{% block head %}
<link rel="stylesheet" href="/static/transaction.css" />
<title>Update Transaction: {{ description }}</title>
{% endblock %}
{% block main %}
<h1>Update Transaction</h1>
<nav>
<sl-breadcrumb>
<sl-breadcrumb-item href="/">Transactions</sl-breadcrumb-item>
<sl-breadcrumb-item>{{ description }}</sl-breadcrumb-item>
</sl-breadcrumb>
</nav>
<form name="transaction">
<p>
<sl-input label="Date" value="March 2nd, 2025" readonly></sl-input>
</p>
<p>
<sl-input label="Description" value="{{ description }}" readonly></sl-input>
</p>
<p>
<sl-input
type="number"
min="0.01"
step="0.01"
label="Amount"
name="amount"
value="{{ amount }}"
></sl-input>
</p>
<p><sl-textarea label="Notes" name="notes"></sl-textarea></p>
<sl-details summary="Take Photo" id="photo-box">
<p class="fallback">Your browser does not support taking photos.</p>
<div id="photo-view" class="invisible">
<div class="workspace">
<video class="invisible"></video>
</div>
<div class="buttons">
<div>
<sl-tooltip content="Take Photo" placement="left">
<sl-icon-button name="camera" label="Take Photo"></sl-icon-button>
</sl-tooltip>
</div>
<div>
<sl-tooltip content="Crop" placement="left">
<sl-icon-button name="crop" label="Crop"></sl-icon-button>
</sl-tooltip>
</div>
<div>
<sl-tooltip content="Start Over" placement="left">
<sl-icon-button
name="trash"
label="Start Over"
></sl-icon-button>
</sl-tooltip>
</div>
</div>
</div>
</sl-details>
<footer>
<sl-button type="reset" variant="secondary">Reset</sl-button
><sl-button type="submit" variant="primary">Submit</sl-button>
</footer>
</form>
{% endblock %}
{% block scripts %}
<script src="/static/transaction.js"></script>
{% endblock %}