Compare commits

...

8 Commits

Author SHA1 Message Date
dc326dfd94 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 34s
2026-04-05 22:26:49 +00:00
2dd4c8ac47 don't normalize quote timestamp to UTC 2026-04-06 00:24:41 +02:00
777027d471 use YMD date display format so i don't have to fight americans 2026-04-05 23:37:09 +02:00
f09af791e2 fetch newest quote for dashboard, helpers 2026-04-05 23:14:49 +02:00
26be03ba31 working quote submission (with limits for now)
- must have two lines
- can only submit timestamp in system timezone
2026-04-05 22:11:43 +02:00
b0d86efae6 why was readme not given a file extension 2026-04-05 18:12:51 +02:00
f6337104cf oops, forgot about page titles 2026-04-05 15:51:22 +02:00
3ab3567ac3 Name::get_all, quotes page stub, quote add UI work, icons 2026-04-05 15:48:30 +02:00
17 changed files with 311 additions and 50 deletions

39
Cargo.lock generated
View File

@@ -185,6 +185,31 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum-extra"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76"
dependencies = [
"axum",
"axum-core",
"bytes",
"form_urlencoded",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"serde_core",
"serde_html_form",
"serde_path_to_error",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "base32" name = "base32"
version = "0.5.1" version = "0.5.1"
@@ -934,6 +959,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"axum", "axum",
"axum-extra",
"base32", "base32",
"base64", "base64",
"chrono", "chrono",
@@ -1289,6 +1315,19 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_html_form"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
dependencies = [
"form_urlencoded",
"indexmap",
"itoa",
"ryu",
"serde_core",
]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.149" version = "1.0.149"

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
argon2 = "0.5.3" argon2 = "0.5.3"
axum = "0.8.8" axum = "0.8.8"
axum-extra = { version = "0.12.5", features = ["form"] }
base32 = "0.5.1" base32 = "0.5.1"
base64 = "0.22.1" base64 = "0.22.1"
chrono = { version = "0.4.43", features = ["serde"] } chrono = { version = "0.4.43", features = ["serde"] }

View File

View File

@@ -157,6 +157,20 @@ impl Name {
None => Err(PersonError::NoNameWithId(id)), None => Err(PersonError::NoNameWithId(id)),
} }
} }
pub fn get_all(conn: &Connection) -> Result<Vec<Name>, PersonError> {
Ok(conn
.prepare("SELECT id, is_primary, person_id, created_by, name FROM names")?
.query_map((), |r| {
Ok(Name {
id: r.get(0)?,
is_primary: r.get(1)?,
person_id: r.get(2)?,
created_by: r.get(3)?,
name: r.get(4)?,
})
})?
.collect::<Result<Vec<Name>, _>>()?)
}
pub fn set_primary(&mut self, conn: &Connection) -> Result<(), PersonError> { pub fn set_primary(&mut self, conn: &Connection) -> Result<(), PersonError> {
if self.is_primary { if self.is_primary {
return Err(PersonError::AlreadyPrimary); return Err(PersonError::AlreadyPrimary);

View File

@@ -1,5 +1,5 @@
use axum::{http::StatusCode, response::IntoResponse}; use axum::{http::StatusCode, response::IntoResponse};
use chrono::{DateTime, FixedOffset}; use chrono::{DateTime, FixedOffset, Utc};
use rusqlite::{Connection, OptionalExtension}; use rusqlite::{Connection, OptionalExtension};
use serde::Serialize; use serde::Serialize;
use uuid::Uuid; use uuid::Uuid;
@@ -34,6 +34,15 @@ pub enum QuoteError {
DatabaseError(#[from] DatabaseError), DatabaseError(#[from] DatabaseError),
} }
impl Quote {
pub fn get_creation_timestamp(&self) -> DateTime<Utc> {
// unwrap here because all IDs use UUIDv7
let (s, n) = self.id.get_timestamp().unwrap().to_unix();
// unwrap here because timestamps held by UUIDs are valid by spec
DateTime::from_timestamp(s as i64, n).unwrap()
}
}
impl Quote { impl Quote {
pub fn total_count(conn: &Connection) -> Result<i64, QuoteError> { pub fn total_count(conn: &Connection) -> Result<i64, QuoteError> {
Ok(conn.query_row("SELECT COUNT(*) FROM quotes", (), |r| r.get(0))?) Ok(conn.query_row("SELECT COUNT(*) FROM quotes", (), |r| r.get(0))?)
@@ -92,6 +101,18 @@ impl Quote {
public, public,
}) })
} }
pub fn get_newest(conn: &Connection) -> Result<Option<Quote>, QuoteError> {
let id: Option<Uuid> = conn
.query_row("SELECT id FROM quotes ORDER BY id DESC LIMIT 1", (), |r| {
r.get(0)
})
.optional()?;
match id {
Some(id) => Ok(Some(Self::get_by_id(conn, id)?)),
None => Ok(None),
}
}
pub fn create( pub fn create(
conn: &Connection, conn: &Connection,
lines: Vec<(String, Name)>, lines: Vec<(String, Name)>,

View File

@@ -5,7 +5,7 @@ use crate::{users::User, web::icons};
// (SHOWTEXT, LINK, ICON, REQUIRES_LOG_IN) // (SHOWTEXT, LINK, ICON, REQUIRES_LOG_IN)
const LINKS: &[(&str, &str, &str, bool)] = &[ const LINKS: &[(&str, &str, &str, bool)] = &[
("Dashboard", "/dashboard", icons::LAYOUT_DASHBOARD, false), ("Dashboard", "/dashboard", icons::LAYOUT_DASHBOARD, false),
("Quotes", "#quotes", icons::SCROLL_TEXT, false), ("Quotes", "/quotes", icons::SCROLL_TEXT, false),
("Photos", "#photos", icons::FILE_IMAGE, false), ("Photos", "#photos", icons::FILE_IMAGE, false),
("Persons", "/persons", icons::CONTACT, false), ("Persons", "/persons", icons::CONTACT, false),
("Tags", "/tags", icons::TAG, false), ("Tags", "/tags", icons::TAG, false),

View File

@@ -25,7 +25,7 @@ pub fn quote(quote: &Quote) -> Markup {
} }
} }
div class="flex flex-row text-neutral-400 mt-auto pt-4 items-center font-light text-xs" { div class="flex flex-row text-neutral-400 mt-auto pt-4 items-center font-light text-xs" {
p {(quote.timestamp.format("%d/%m/%Y %H:%M"))} p {(quote.timestamp.format("%Y-%m-%d %H:%M"))}
@if let Some(loc) = &quote.location { @if let Some(loc) = &quote.location {
span class="ml-3 scale-[.5]"{(PreEscaped(icons::MAP_PIN))} p { (loc) } span class="ml-3 scale-[.5]"{(PreEscaped(icons::MAP_PIN))} p { (loc) }
} }

View File

@@ -11,6 +11,7 @@ pub const LAYOUT_DASHBOARD: &str = include_str!("layout-dashboard.svg");
pub const LOG_OUT: &str = include_str!("log-out.svg"); pub const LOG_OUT: &str = include_str!("log-out.svg");
pub const MAP_PIN: &str = include_str!("map-pin.svg"); pub const MAP_PIN: &str = include_str!("map-pin.svg");
pub const PEN: &str = include_str!("pen.svg"); pub const PEN: &str = include_str!("pen.svg");
pub const PLUS: &str = include_str!("plus.svg");
pub const QUOTE: &str = include_str!("quote.svg"); pub const QUOTE: &str = include_str!("quote.svg");
pub const SCROLL_TEXT: &str = include_str!("scroll-text.svg"); pub const SCROLL_TEXT: &str = include_str!("scroll-text.svg");
pub const SERVER: &str = include_str!("server.svg"); pub const SERVER: &str = include_str!("server.svg");

1
src/web/icons/plus.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus-icon lucide-plus"><path d="M5 12h14"/><path d="M12 5v14"/></svg>

After

Width:  |  Height:  |  Size: 272 B

View File

@@ -24,8 +24,9 @@ const LINKS: &[(&str, &str, &str)] = &[
pub async fn page(req: Request) -> Result<Markup, CompositeError> { pub async fn page(req: Request) -> Result<Markup, CompositeError> {
let u = User::authenticate(req.headers()).ok().flatten(); let u = User::authenticate(req.headers()).ok().flatten();
let mut conn = database::conn()?; let conn = database::conn()?;
let tx = conn.transaction()?;
let newest_quote = Quote::get_newest(&conn)?;
Ok(base( Ok(base(
"Dashboard | Mnemosyne", "Dashboard | Mnemosyne",
@@ -35,8 +36,15 @@ pub async fn page(req: Request) -> Result<Markup, CompositeError> {
div class="mx-auto max-w-4xl mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4" { div class="mx-auto max-w-4xl mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4" {
div class="flex flex-col" { div class="flex flex-col" {
p {"Newest Quote"} p {"Newest Quote"}
p class="text-neutral-500 font-light mb-4" {"This just in! This quote was added 15s ago."} @if let Some(q) = newest_quote {
div class="flex-1 [&>div]:h-full" {(quote(&sample_quote_1()))} p class="text-neutral-500 font-light mb-4" {
"This just in! This quote was added "
(format_time_ago(q.get_creation_timestamp())) " ago."
}
div class="flex-1 [&>div]:h-full" {(quote(&q))}
} @else {
p class="text-neutral-500 font-light mb-4" {"No quotes yet."}
}
} }
div class="flex flex-col" { div class="flex flex-col" {
p {"Quote of the Day"} p {"Quote of the Day"}
@@ -60,25 +68,25 @@ pub async fn page(req: Request) -> Result<Markup, CompositeError> {
} }
div class="mx-auto max-w-4xl mt-4 flex flex-row gap-2" { div class="mx-auto max-w-4xl mt-4 flex flex-row gap-2" {
(chip(html!({ (chip(html!({
@match Quote::total_count(&tx) { @match Quote::total_count(&conn) {
Ok(count) => {(count) " QUOTES TOTAL"}, Ok(count) => {(count) " QUOTES TOTAL"},
Err(_) => span class="text-red-400" {"QUOTE COUNT ERR"}, Err(_) => span class="text-red-400" {"QUOTE COUNT ERR"},
} }
}))) })))
(chip(html!({ (chip(html!({
@match Person::total_count(&tx) { @match Person::total_count(&conn) {
Ok(count) => {(count) " PERSONS TOTAL"}, Ok(count) => {(count) " PERSONS TOTAL"},
Err(_) => span class="text-red-400" {"PERSON COUNT ERR"}, Err(_) => span class="text-red-400" {"PERSON COUNT ERR"},
} }
}))) })))
(chip(html!({ (chip(html!({
@match Tag::total_count(&tx) { @match Tag::total_count(&conn) {
Ok(count) => {(count) " TAGS TOTAL"}, Ok(count) => {(count) " TAGS TOTAL"},
Err(_) => span class="text-red-400" {"TAG COUNT ERR"} Err(_) => span class="text-red-400" {"TAG COUNT ERR"}
} }
}))) })))
(chip(html!({ (chip(html!({
@match User::total_count(&tx) { @match User::total_count(&conn) {
Ok(count) => {(count) " USERS TOTAL"}, Ok(count) => {(count) " USERS TOTAL"},
Err(_) => span class="text-red-400" {"USER COUNT ERR"} Err(_) => span class="text-red-400" {"USER COUNT ERR"}
} }
@@ -90,41 +98,6 @@ pub async fn page(req: Request) -> Result<Markup, CompositeError> {
)) ))
} }
fn sample_quote_1() -> Quote {
Quote {
id: Uuid::now_v7(),
public: true,
location: Some(String::from("Poznań")),
context: Some(String::from("Wykład z językoznawstwa")),
created_by: Uuid::max(),
timestamp: DateTime::from(Utc::now()),
lines: vec![
QuoteLine {
id: Uuid::now_v7(),
content: String::from("Nie wiem, czy są tutaj osoby fanowskie zipline-ów?"),
attribution: Name {
id: Uuid::nil(),
created_by: Uuid::max(),
person_id: Uuid::now_v7(),
is_primary: true,
name: String::from("dr. Barbara Konat"),
},
},
QuoteLine {
id: Uuid::now_v7(),
content: String::from("Taka uprząż co robi pziuuum!"),
attribution: Name {
id: Uuid::nil(),
created_by: Uuid::max(),
person_id: Uuid::now_v7(),
is_primary: true,
name: String::from("dr. Barbara Konat"),
},
},
],
}
}
fn sample_quote_2() -> Quote { fn sample_quote_2() -> Quote {
Quote { Quote {
id: Uuid::now_v7(), id: Uuid::now_v7(),
@@ -159,3 +132,13 @@ fn sample_quote_2() -> Quote {
], ],
} }
} }
fn format_time_ago(dt: DateTime<Utc>) -> String {
let secs = Utc::now().signed_duration_since(dt).num_seconds();
match secs {
..60 => format!("{}s", secs),
60..3600 => format!("{}m", secs / 60),
3600..86400 => format!("{}h", secs / 3600),
_ => format!("{}d", secs / 86400),
}
}

View File

@@ -20,7 +20,7 @@ pub async fn page(req: Request) -> Result<Response, CompositeError> {
let logs = LogEntry::get_all(&tx)?; let logs = LogEntry::get_all(&tx)?;
Ok(base( Ok(base(
"Persons | Mnemosyne", "Logs | Mnemosyne",
html!( html!(
(nav(Some(&u), req.uri().path())) (nav(Some(&u), req.uri().path()))

View File

@@ -9,6 +9,7 @@ pub mod index;
pub mod login; pub mod login;
pub mod logs; pub mod logs;
pub mod persons; pub mod persons;
pub mod quotes;
pub mod tags; pub mod tags;
pub mod users; pub mod users;
pub mod usersettings; pub mod usersettings;
@@ -30,6 +31,10 @@ pub fn pages() -> Router {
.route("/persons", get(persons::page)) .route("/persons", get(persons::page))
.route("/persons/create", post(persons::create)) .route("/persons/create", post(persons::create))
.route("/logs", get(logs::page)) .route("/logs", get(logs::page))
//
.route("/quotes", get(quotes::page))
.route("/quotes/add", get(quotes::add::page))
.route("/quotes/add-form", post(quotes::add::form))
} }
pub fn base(title: &str, inner: Markup) -> Markup { pub fn base(title: &str, inner: Markup) -> Markup {

43
src/web/pages/quotes.rs Normal file
View File

@@ -0,0 +1,43 @@
use axum::{
extract::Request,
response::{IntoResponse, Response},
};
use maud::{PreEscaped, html};
use crate::{
error::CompositeError,
users::{User, auth::UserAuthenticate},
web::{components::nav::nav, icons, pages::base},
};
pub mod add;
pub async fn page(req: Request) -> Result<Response, CompositeError> {
let u = User::authenticate(req.headers())?;
Ok(base(
"Quotes | Mnemosyne",
html!(
(nav(u.as_ref(), req.uri().path()))
div class="max-w-4xl mx-auto px-2" {
div class="my-4 flex justify-between" {
p class="flex items-center gap-2" {
span class="text-neutral-500" {(PreEscaped(icons::SCROLL_TEXT))}
span class="text-2xl font-semibold font-lora" {"Quotes"}
}
@if let Some(_) = u {
a href="/quotes/add" class="group border rounded flex items-center gap-1 px-2 border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-400/5 hover:bg-neutral-400/10" {
span class="text-neutral-300 group-hover:text-neutral-200" {(PreEscaped(icons::PLUS))}
span class="text-neutral-300 group-hover:text-neutral-200" {"Add quote"}
}
}
}
input class="border w-full border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-950/50 p-2 rounded"
placeholder="Search for quotes...";
div class="text-center p-4" {"Search not yet implemented."}
}
),
)
.into_response())
}

153
src/web/pages/quotes/add.rs Normal file
View File

@@ -0,0 +1,153 @@
use axum::{
Json,
extract::Request,
http::HeaderMap,
response::{IntoResponse, Response},
};
use axum_extra::extract::Form;
use chrono::{TimeZone, Utc};
use chrono_tz::Europe::Warsaw;
use maud::{PreEscaped, html};
use serde::Deserialize;
use uuid::Uuid;
use crate::{
database,
error::CompositeError,
logs::{LogAction, LogEntry},
persons::Name,
quotes::Quote,
users::{
User,
auth::{UserAuthRequired, UserAuthenticate},
},
web::{components::nav::nav, icons, pages::base},
};
pub async fn page(req: Request) -> Result<Response, CompositeError> {
let u = User::authenticate(req.headers())?;
let conn = database::conn()?;
let names = Name::get_all(&conn)?;
Ok(base(
"Add Quote | Mnemosyne",
html!(
(nav(u.as_ref(), req.uri().path()))
div class="max-w-4xl mx-auto px-2" {
div class="my-4 flex justify-between" {
p class="flex items-center gap-2" {
span class="text-neutral-500" {(PreEscaped(icons::SCROLL_TEXT))}
span class="text-2xl font-semibold font-lora" {"Quote Maker"}
}
}
form method="post" action="/quotes/add-form"
class="border border-neutral-200/25 bg-neutral-200/5 rounded-md p-4 flex flex-col" {
@for i in 1..=2 {
div class="flex justify-between gap-4" {
div class="flex flex-col flex-1" {
label class="w-full" {
p class="mb-1" {(format!("Quote Line #{i}"))}
input type="text" name="quoteline" placeholder="They said..." autocomplete="off"
class="px-2 py-1 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25";
}
}
div class="flex flex-col" {
label {
p class="mb-1" {(format!("Quote Author #{i}"))}
select name="quoteauthor" autocomplete="off"
class="px-2 py-1.5 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25"{
option {"--"}
@for name in &names {
option value=(name.id.to_string()) {(name.name)}
}
}
}
}
}
}
hr class="border-neutral-200/25 my-4";
div class="flex gap-4 justify-between" {
div class="flex flex-col flex-1" {
label class="w-full"{
p class="mb-1" {"Location"}
input type="text" name="location" autocomplete="off" placeholder="Right there!"
class="px-2 py-1 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25";
}
}
div class="flex flex-col flex-1" {
label class="w-full" {
p class="mb-1" {"Time of utterance"}
input type="hidden" name="tz_offset" id="tz_offset" value="0";
script { (PreEscaped("document.getElementById('tz_offset').value = new Date().getTimezoneOffset();")) }
input type="datetime-local" name="time" autocomplete="off"
class="px-2 py-1 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25";
}
}
}
div class="flex gap-4 justify-between" {
div class="flex flex-col flex-1" {
label class="w-full" {
p class="mb-1" {"Context"}
input type="text" name="context" autocomplete="off" placeholder="It was like this.."
class="px-2 py-1 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25";
}
}
button type="submit" class="border mt-auto mb-2 cursor-pointer rounded h-fit px-2 py-1 bg-neutral-200/5 border-neutral-200/25 hover:border-neutral-200/45 hover:bg-neutral-200/15" {
"Submit"
}
}
}
}
),
)
.into_response())
}
#[derive(Deserialize, Debug)]
pub struct IncomingQuote {
#[serde(rename = "quoteline")]
lines: Vec<String>,
#[serde(rename = "quoteauthor")]
authors: Vec<Uuid>,
location: String,
time: String,
tz_offset: Option<i32>,
context: String,
}
pub async fn form(
headers: HeaderMap,
Form(form): Form<IncomingQuote>,
) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?;
let mut conn = database::conn()?;
let tx = conn.transaction()?;
let authors = form
.authors
.into_iter()
.map(|nid| Name::get_by_id(&tx, nid).unwrap());
let lines = form.lines.into_iter().zip(authors).collect();
let offset = form
.tz_offset
.and_then(|mins| chrono::FixedOffset::west_opt(mins * 60))
.unwrap_or_else(|| chrono::FixedOffset::west_opt(0).unwrap());
let timestamp = chrono::NaiveDateTime::parse_from_str(&form.time, "%Y-%m-%dT%H:%M")
.map(|ndt| offset.from_local_datetime(&ndt).unwrap())
.unwrap_or_else(|_| Utc::now().with_timezone(&Warsaw).fixed_offset());
let context = match form.context.trim() {
"" => None,
s => Some(s.to_string()),
};
let location = match form.location.trim() {
"" => None,
s => Some(s.to_string()),
};
let q = Quote::create(&tx, lines, timestamp, context, location, u.id, false)?;
LogEntry::new(&tx, u, LogAction::CreateQuote { id: q.id })?;
tx.commit()?;
Ok(Json(q).into_response())
}

View File

@@ -25,7 +25,7 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
let conn = database::conn()?; let conn = database::conn()?;
Ok(base( Ok(base(
"Users | Mnemosyne", "Create User | Mnemosyne",
html!( html!(
(nav(u.as_ref(), req.uri().path())) (nav(u.as_ref(), req.uri().path()))

View File

@@ -23,7 +23,7 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
let u = User::authenticate(req.headers())?; let u = User::authenticate(req.headers())?;
Ok(base( Ok(base(
"Persons | Mnemosyne", "User Settings | Mnemosyne",
html!( html!(
(nav(u.as_ref(), req.uri().path())) (nav(u.as_ref(), req.uri().path()))

File diff suppressed because one or more lines are too long