Compare commits

...

3 commits

Author SHA1 Message Date
4f611b9544 set up forgejo actions
All checks were successful
/ test (push) Successful in 35s
2025-12-01 22:10:28 -06:00
d925573629 hash statics 2025-12-01 15:23:56 -06:00
a45bf45015 address style improvments 2025-11-28 17:05:06 -06:00
14 changed files with 237 additions and 1480 deletions

View file

@ -0,0 +1,6 @@
on: [push, workflow_dispatch]
jobs:
test:
runs-on: playwright-latest
steps:
- run: echo All good!

7
.gitignore vendored
View file

@ -2,5 +2,8 @@
e2e/node_modules e2e/node_modules
e2e/playwright-report e2e/playwright-report
e2e/test-results e2e/test-results
some_user.db /some_user.db
dbs /dbs
/hashed_static
/users.db
/.sqlx

1461
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,15 +3,13 @@ name = "mascarpone"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[build]
rustflags = ["--cfg=sqlx_macros_unstable"]
[dependencies] [dependencies]
anyhow = "1.0.100" anyhow = "1.0.100"
axum = { version = "0.8.6", features = ["macros", "form"] } axum = { version = "0.8.6", features = ["macros", "form"] }
axum-extra = { version = "0.10.3", features = ["form"] } axum-extra = { version = "0.10.3", features = ["form"] }
axum-htmx = "0.8.1" axum-htmx = "0.8.1"
axum-login = "0.18.0" axum-login = "0.18.0"
cache_bust = { version = "0.2.0", features = ["macro"] }
chrono = { version = "0.4.42", features = ["clock", "alloc"] } chrono = { version = "0.4.42", features = ["clock", "alloc"] }
clap = { version = "4.5.53", features = ["derive"] } clap = { version = "4.5.53", features = ["derive"] }
http = "1.3.1" http = "1.3.1"
@ -38,6 +36,5 @@ tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
vcard = "0.4.13" vcard = "0.4.13"
[dev-dependencies] [build-dependencies]
cargo-watch = "8.5.3" cache_bust = "0.2.0"
systemfd = "0.4.6"

View file

@ -16,13 +16,31 @@ refresh_sqlx_db() {
done done
} }
_env() {
refresh_sqlx_db > /dev/null
DATABASE_URL=sqlite:some_user.db \
CACHE_BUST_ASSETS_DIR=static \
"$@"
}
_cargo() {
_env cargo "$@"
}
edit() {
_env nvim
}
deploy_to_server() { deploy_to_server() {
where="$1" where="$1"
refresh_sqlx_db _cargo build --release
env DATABASE_URL=sqlite:some_user.db cargo build --release
rsync -v -essh ./target/release/mascarpone "$where:~" \ rsync -v -essh ./target/release/mascarpone "$where:~" \
&& rsync -rav -essh ./static "$where:~" \ && rsync -rav -essh ./hashed_static "$where:~/" \
&& ssh -t "$where" "sudo mv -f mascarpone /usr/bin/ && sudo rm -rf /var/local/mascarpone/static && sudo mv -f static /var/local/mascarpone/ && sudo systemctl restart mascarpone" && ssh -t "$where" "sudo mv -f mascarpone /usr/bin/ && sudo rm -rf /var/local/mascarpone/hashed_static && sudo mv -f hashed_static /var/local/mascarpone/ && sudo systemctl restart mascarpone"
}
dev() {
_cargo run -- serve
} }
"$@" "$@"

12
build.rs Normal file
View file

@ -0,0 +1,12 @@
use cache_bust::CacheBust;
fn main() {
println!("cargo:rerun-if-changed=migrations");
let cache_bust = CacheBust::builder()
.in_dir("static".to_owned())
.out_dir("hashed_static".to_owned())
.build();
cache_bust.hash_dir().expect("Cache busting failed");
}

View file

@ -1,6 +1,4 @@
[tools] [tools]
"cargo:systemfd" = "latest"
"watchexec" = "latest"
"rust-analyzer" = "latest" "rust-analyzer" = "latest"
"jj" = "latest" "jj" = "latest"
"pnpm" = "latest" "pnpm" = "latest"

View file

@ -182,19 +182,12 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> {
.route_layer(login_required!(Backend, login_url = "/login")) .route_layer(login_required!(Backend, login_url = "/login"))
.merge(auth::router()) .merge(auth::router())
.merge(ics::router()) .merge(ics::router())
.nest_service("/static", ServeDir::new("./static")) .nest_service("/static", ServeDir::new("./hashed_static"))
.layer(auth_layer) .layer(auth_layer)
.with_state(state); .with_state(state);
let mut listenfd = listenfd::ListenFd::from_env(); let listener = TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
let listener = match listenfd.take_tcp_listener(0)? { tracing::debug!("Starting axum on 0.0.0.0:{}...", port);
Some(listener) => {
listener.set_nonblocking(true)?;
TcpListener::from_std(listener)
}
None => TcpListener::bind(format!("0.0.0.0:{}", port)).await,
}?;
tracing::debug!("Starting axum on 0.0.0.0:3000...");
axum::serve(listener, app) axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal(deletion_task.abort_handle())) .with_graceful_shutdown(shutdown_signal(deletion_task.abort_handle()))
.await .await

View file

@ -6,6 +6,7 @@ use axum::{
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
routing::{get, post}, routing::{get, post},
}; };
use cache_bust::asset;
use maud::{DOCTYPE, html}; use maud::{DOCTYPE, html};
use serde::Deserialize; use serde::Deserialize;
@ -78,8 +79,8 @@ mod get {
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {} script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {} script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {} script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
link rel="stylesheet" type="text/css" href="/static/index.css"; link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
link rel="stylesheet" type="text/css" href="/static/login.css"; link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css")));
title { "Mascarpone" } title { "Mascarpone" }
} }
body hx-ext="response-targets" { body hx-ext="response-targets" {

View file

@ -6,6 +6,7 @@ use axum::{
routing::{delete, get, post, put}, routing::{delete, get, post, put},
}; };
use axum_extra::extract::Form; use axum_extra::extract::Form;
use cache_bust::asset;
use chrono::DateTime; use chrono::DateTime;
use maud::{Markup, PreEscaped, html}; use maud::{Markup, PreEscaped, html};
use serde::Deserialize; use serde::Deserialize;
@ -19,7 +20,7 @@ use crate::models::user::AuthSession;
use crate::models::{HydratedContact, JournalEntry}; use crate::models::{HydratedContact, JournalEntry};
use crate::{AppError, AppState}; use crate::{AppError, AppState};
#[derive(serde::Serialize)] #[derive(serde::Serialize, Debug)]
pub struct Address { pub struct Address {
pub id: DbId, pub id: DbId,
pub contact_id: DbId, pub contact_id: DbId,
@ -100,7 +101,7 @@ mod get {
.text_body; .text_body;
Ok(layout.render( Ok(layout.render(
Some(vec!["/static/contact.css", "/static/journal.css"]), Some(vec![asset!("contact.css"), asset!("journal.css")]),
html! { html! {
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" } a href=(format!("/contact/{}/edit", contact_id)) { "Edit" }
@ -210,7 +211,7 @@ mod get {
.text_body .text_body
.unwrap_or(String::new()); .unwrap_or(String::new());
Ok(layout.render(Some(vec!["/static/contact.css"]), html! { Ok(layout.render(Some(vec![asset!("contact.css")]), html! {
form hx-ext="response-targets" { form hx-ext="response-targets" {
div { div {
input type="button" value="Save" hx-put=(cid_url) hx-target-error="#error"; input type="button" value="Save" hx-put=(cid_url) hx-target-error="#error";
@ -248,24 +249,31 @@ mod get {
label { "addresses" } label { "addresses" }
div x-data=(json!({ "addresses": addresses, "new_label": "", "new_address": "" })) { div x-data=(json!({ "addresses": addresses, "new_label": "", "new_address": "" })) {
template x-for="(address, index) in addresses" x-bind:key="index" { template x-for="(address, index) in addresses" x-bind:key="index" {
div { .address-input {
input name="address_label" x-show="addresses.length > 1" x-model="address.label" placeholder="label"; input name="address_label" x-show="addresses.length" x-model="address.label" placeholder="label";
input name="address_value" x-model="address.value" placeholder="address"; .grow-wrap x-bind:data-replicated-value="address.value" {
textarea name="address_value" x-model="address.value" placeholder="address" {}
} }
} }
div { }
input x-show="addresses.length > 1" name="address_label" x-model="new_label" placeholder="label"; .address-input {
input name="address_value" x-model="new_address" placeholder="new address"; input x-show="addresses.length" name="address_label" x-model="new_label" placeholder="label";
.grow-wrap x-bind:data-replicated-value="new_address" {
textarea name="address_value" x-model="new_address" placeholder="new address" {}
}
} }
input type="button" value="Add" x-on:click="addresses.push({ label: new_label, value: new_address }); new_label = ''; new_address = ''"; input type="button" value="Add" x-on:click="addresses.push({ label: new_label, value: new_address }); new_label = ''; new_address = ''";
} }
} }
div #text_body {
div { "Free text (supports markdown)" }
.grow-wrap data-replicated-value=(text_body) { .grow-wrap data-replicated-value=(text_body) {
textarea name="text_body" textarea name="text_body"
onInput="this.parentNode.dataset.replicatedValue = this.value" onInput="this.parentNode.dataset.replicatedValue = this.value"
{ (text_body) } { (text_body) }
} }
} }
}
})) }))
} }
} }

View file

@ -1,5 +1,6 @@
use axum::extract::State; use axum::extract::State;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use cache_bust::asset;
use chrono::{Local, NaiveDate, TimeDelta}; use chrono::{Local, NaiveDate, TimeDelta};
use maud::{Markup, html}; use maud::{Markup, html};
use sqlx::sqlite::SqlitePool; use sqlx::sqlite::SqlitePool;
@ -230,7 +231,7 @@ pub mod get {
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
Ok(layout.render( Ok(layout.render(
Some(vec!["/static/home.css", "/static/journal.css"]), Some(vec![asset!("home.css"), asset!("journal.css")]),
html! { html! {
(freshness_section(&freshens)?) (freshness_section(&freshens)?)
(birthdays_section(&prev_birthdays, &upcoming_birthdays)?) (birthdays_section(&prev_birthdays, &upcoming_birthdays)?)

View file

@ -1,6 +1,6 @@
use axum::RequestPartsExt; use axum::RequestPartsExt;
use axum::extract::FromRequestParts; use axum::extract::FromRequestParts;
// use axum::response::{IntoResponse, Redirect}; use cache_bust::asset;
use http::request::Parts; use http::request::Parts;
use maud::{DOCTYPE, Markup, html}; use maud::{DOCTYPE, Markup, html};
use sqlx::FromRow; use sqlx::FromRow;
@ -62,14 +62,14 @@ impl Layout {
(DOCTYPE) (DOCTYPE)
html { html {
head { head {
link rel="stylesheet" type="text/css" href="/static/index.css"; link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
meta name="viewport" content="width=device-width"; meta name="viewport" content="width=device-width";
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {} script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {} script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {} script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
@if let Some(hrefs) = css { @if let Some(hrefs) = css {
@for href in hrefs { @for href in hrefs {
link rel="stylesheet" type="text/css" href=(href); link rel="stylesheet" type="text/css" href=(format!("/static/{}", href));
} }
} }
} }

View file

@ -4,6 +4,7 @@ use axum::{
routing::{delete, get, post, put}, routing::{delete, get, post, put},
}; };
use axum_extra::extract::Form; use axum_extra::extract::Form;
use cache_bust::asset;
use maud::{Markup, html}; use maud::{Markup, html};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
@ -61,7 +62,7 @@ mod get {
let ics_path: Option<String> = ics_path.0; let ics_path: Option<String> = ics_path.0;
Ok(layout.render( Ok(layout.render(
Some(vec!["static/settings.css"]), Some(vec![asset!("settings.css")]),
html! { html! {
h2 { "Birthdays Calendar URL" } h2 { "Birthdays Calendar URL" }
(calendar_link(ics_path)) (calendar_link(ics_path))

View file

@ -33,19 +33,28 @@ main {
grid-template-columns: min-content auto; grid-template-columns: min-content auto;
row-gap: 0.5em; row-gap: 0.5em;
.label { .label[data-is-empty="false"] {
color: var(--line-color); color: var(--line-color);
text-align: right; text-align: right;
margin-right: 0.5em;
}
.value {
white-space: pre-wrap;
} }
} }
#text_body { #text_body {
margin-top: 1em; margin-top: 1em;
p+p { p {
margin-top: 0.5em; margin-top: 0.5em;
} }
p:first-child {
margin-top: 0;
}
em { em {
font-style: italic; font-style: italic;
} }
@ -53,6 +62,40 @@ main {
strong { strong {
font-weight: bold; font-weight: bold;
} }
li {
list-style: disc inside;
}
h1 {
margin-block: 0.83em;
font-size: 1.50em;
}
h2 {
margin-block: 1.00em;
font-size: 1.17em;
}
h3 {
margin-block: 1.33em;
font-size: 1.00em;
}
h4 {
margin-block: 1.67em;
font-size: 0.83em;
}
h5 {
margin-block: 2.33em;
font-size: 0.67em;
}
blockquote {
padding: 0.1em 0 0.1em 0.5em;
border-left: 2px solid var(--line-color);
}
} }
.grow-wrap { .grow-wrap {
@ -72,14 +115,35 @@ main {
&>textarea, &>textarea,
&::after { &::after {
/* Identical styling required!! */ /* Identical styling required!! */
margin-top: 1em;
width: 100%; width: 100%;
max-width: 12em;
font: inherit;
border: 1px solid gray;
box-sizing: border-box;
margin: 2px 0;
padding: 0.25em;
/* Place on top of each other */ /* Place on top of each other */
grid-area: 1 / 1 / 2 / 2; grid-area: 1 / 1 / 2 / 2;
} }
} }
@media only screen and (max-width: 650px) {
.address-input+.address-input {
margin-top: 0.5em;
}
}
#text_body {
margin-top: 1em;
.grow-wrap>textarea,
.grow-wrap::after {
/* Identical styling required!! */
max-width: 100%;
}
}
.hint { .hint {
font-size: small; font-size: small;
} }