From 29804e75e50678d87a2e5fc7d8b3f4321e2d4bf6 Mon Sep 17 00:00:00 2001 From: jmanczak Date: Tue, 5 May 2026 14:40:17 +0200 Subject: [PATCH 1/5] allow unused icons --- src/web/icons/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web/icons/mod.rs b/src/web/icons/mod.rs index 9c9b71c..e783d90 100644 --- a/src/web/icons/mod.rs +++ b/src/web/icons/mod.rs @@ -1,3 +1,4 @@ +#![allow(unused)] // Below icons sourced from https://lucide.dev pub const ARROW_RIGHT: &str = include_str!("arrow-right.svg"); pub const CALENDAR_1: &str = include_str!("calendar-1.svg"); From 76ac36c4fb167ab6f4bd55f3da6e8355d9389f95 Mon Sep 17 00:00:00 2001 From: jmanczak Date: Tue, 5 May 2026 15:23:49 +0200 Subject: [PATCH 2/5] remove MnemoConf::new --- src/config.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index 302f843..224238b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,9 +20,6 @@ pub struct MnemoConf { } impl MnemoConf { - pub fn new() -> Self { - MnemoConf::default() - } pub async fn load(conn: &mut sqlx::PgConnection) -> Result { let row: Option = sqlx::query_scalar("SELECT config FROM mnemoconf LIMIT 1") From 032d450af21f990719953dc1ca1c651a661090c4 Mon Sep 17 00:00:00 2001 From: jmanczak Date: Tue, 5 May 2026 23:52:09 +0200 Subject: [PATCH 3/5] barebones quote-specific page --- src/web/pages/mod.rs | 1 + src/web/pages/quotes.rs | 3 ++- src/web/pages/quotes/id.rs | 53 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/web/pages/quotes/id.rs diff --git a/src/web/pages/mod.rs b/src/web/pages/mod.rs index 6cd271a..fbc0e0a 100644 --- a/src/web/pages/mod.rs +++ b/src/web/pages/mod.rs @@ -50,6 +50,7 @@ pub fn pages() -> Router { .route("/logs", get(logs::page)) // .route("/quotes", get(quotes::page)) + .route("/quotes/{id}", get(quotes::id::page)) .route("/quotes/add", get(quotes::add::page)) .route("/quotes/add-form", post(quotes::add::form)) // diff --git a/src/web/pages/quotes.rs b/src/web/pages/quotes.rs index a25175d..bc11e3a 100644 --- a/src/web/pages/quotes.rs +++ b/src/web/pages/quotes.rs @@ -18,6 +18,7 @@ use crate::{ }; pub mod add; +pub mod id; #[derive(Deserialize)] pub struct PageQuery { @@ -85,7 +86,7 @@ pub async fn page( } div class="flex flex-col gap-4 mb-8" { @for q in "es { - (quote(q)) + a href=(format!("/quotes/{}", q.id)) {(quote(q))} } div class="flex justify-between items-center mt-4 text-neutral-400" { diff --git a/src/web/pages/quotes/id.rs b/src/web/pages/quotes/id.rs new file mode 100644 index 0000000..1deb7d0 --- /dev/null +++ b/src/web/pages/quotes/id.rs @@ -0,0 +1,53 @@ +use axum::{ + extract::{Path, Request, State}, + response::{IntoResponse, Redirect, Response}, +}; +use maud::{PreEscaped, html}; +use uuid::Uuid; + +use crate::{ + MnemoState, + error::CompositeError, + quotes::Quote, + users::{User, auth::UserAuthenticate}, + web::{ + components::{nav::nav, quote::quote}, + icons, + pages::base, + }, +}; + +pub async fn page( + State(state): State, + Path(id): Path, + req: Request, +) -> Result { + let mut conn = state.pool.acquire().await?; + let u = match User::authenticate(&mut *conn, req.headers()).await? { + Some(u) => u, + None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), + }; + let q = Quote::get_by_id(&mut conn, id).await; + + Ok(base( + "Add Quote | Mnemosyne", + html!( + (nav(&mut conn, Some(&u), req.uri().path()).await) + + div class="max-w-4xl mx-auto px-2" { + div class="my-4 flex justify-between" { + p class="flex items-center gap-2 text-neutral-500" { + (PreEscaped(icons::SCROLL_TEXT)) + span class="font-lora" {"Quote of ID " (id)} + } + } + @if let Ok(q) = q { + (quote(&q)) + } @else { + "Failed to fetch quote. Are you sure it exists?" + } + } + ), + ) + .into_response()) +} From 9eb3332576281896a12b5dc375453462881034ba Mon Sep 17 00:00:00 2001 From: jmanczak Date: Wed, 6 May 2026 00:13:38 +0200 Subject: [PATCH 4/5] forgot to make the 404 page return status 404 --- src/web/pages/notfound.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/web/pages/notfound.rs b/src/web/pages/notfound.rs index 0927f04..e7543cf 100644 --- a/src/web/pages/notfound.rs +++ b/src/web/pages/notfound.rs @@ -1,5 +1,9 @@ -use axum::extract::{Request, State}; -use maud::{Markup, html}; +use axum::{ + extract::{Request, State}, + response::{IntoResponse, Response}, +}; +use http::StatusCode; +use maud::html; use crate::{ MnemoState, @@ -8,13 +12,16 @@ use crate::{ web::{components::nav::nav, pages::base}, }; -pub async fn page(State(state): State, req: Request) -> Result { +pub async fn page( + State(state): State, + req: Request, +) -> Result { let mut conn = state.pool.acquire().await?; let u = User::authenticate(&mut *conn, req.headers()) .await .ok() .flatten(); - Ok(base( + Ok((StatusCode::NOT_FOUND, base( "Not Found | Mnemosyne", html!( (nav(&mut conn, u.as_ref(), req.uri().path()).await) @@ -27,5 +34,5 @@ pub async fn page(State(state): State, req: Request) -> Result Date: Wed, 6 May 2026 00:43:20 +0200 Subject: [PATCH 5/5] =?UTF-8?q?quote=20deletion=20=F0=9F=8E=89=F0=9F=98=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/logs.rs | 11 ++++++++++- src/persons/mod.rs | 4 ++-- src/quotes/mod.rs | 40 +++++++++++++++++++++++++++++++++++--- src/users/permissions.rs | 2 ++ src/web/icons/mod.rs | 1 + src/web/icons/trash.svg | 1 + src/web/pages/mod.rs | 1 + src/web/pages/quotes/id.rs | 35 ++++++++++++++++++++++++++++++++- 8 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 src/web/icons/trash.svg diff --git a/src/logs.rs b/src/logs.rs index 5d0512f..8d13732 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -4,7 +4,7 @@ use strum::{EnumDiscriminants, EnumIter, IntoStaticStr, VariantNames}; use url::Url; use uuid::Uuid; -use crate::{database::DatabaseError, users::User, web::icons}; +use crate::{database::DatabaseError, quotes::Quote, users::User, web::icons}; #[derive(Debug)] pub struct LogEntry { @@ -152,6 +152,9 @@ pub enum LogAction { CreateQuote { id: Uuid, }, + DeleteQuote { + quote: Quote, + }, ManuallyRevokeSession { id: Uuid, }, @@ -179,6 +182,8 @@ impl LogAction { | Self::DeleteTag { id, .. } | Self::ManuallyChangeUsersPassword { id } => Some(*id), + Self::DeleteQuote { quote } => Some(quote.id), + Self::AddPersonName { pid, .. } | Self::DeletePersonName { pid, .. } | Self::SetPersonPrimaryName { pid, .. } => Some(*pid), @@ -225,6 +230,9 @@ impl LogAction { LogAction::CreateQuote { id } => { format!("Created quote of ID {id}") } + LogAction::DeleteQuote { quote } => { + format!("Deleted quote of ID {}", quote.id) + } LogAction::ManuallyRevokeSession { id } => { format!("Revoked session of ID {id}") } @@ -253,6 +261,7 @@ impl LogActionDiscriminant { LAD::DeletePersonName => "Person Name Deletion", LAD::SetPersonPrimaryName => "Person Primary Name Set", LAD::CreateQuote => "Quote Creation", + LAD::DeleteQuote => "Quote Deletion", LAD::ManuallyRevokeSession => "Manual Session Revocation", LAD::ChangeInstanceName => "Instance Name Change", LAD::ChangeDiscordWebhookUrl => "Discord Webhook URL Change", diff --git a/src/persons/mod.rs b/src/persons/mod.rs index f3e066b..d2920d3 100644 --- a/src/persons/mod.rs +++ b/src/persons/mod.rs @@ -2,7 +2,7 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::{PgConnection, Row}; use uuid::Uuid; @@ -14,7 +14,7 @@ pub struct Person { pub primary_name: String, } -#[derive(Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct Name { pub id: Uuid, pub is_primary: bool, diff --git a/src/quotes/mod.rs b/src/quotes/mod.rs index 6d65d2a..cbaa0ff 100644 --- a/src/quotes/mod.rs +++ b/src/quotes/mod.rs @@ -3,7 +3,7 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::{DateTime, NaiveDateTime, Utc}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::{PgConnection, Row}; use uuid::Uuid; @@ -11,7 +11,7 @@ use crate::{database::DatabaseError, persons::Name}; mod webhook; -#[derive(Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct Quote { pub id: Uuid, pub lines: Vec, @@ -22,7 +22,7 @@ pub struct Quote { pub public: bool, } -#[derive(Serialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct QuoteLine { pub id: Uuid, pub attribution: Vec, @@ -290,6 +290,40 @@ impl Quote { public, }) } + + pub async fn delete(self, conn: &mut PgConnection) -> Result<(), QuoteError> { + sqlx::query( + r#" + DELETE FROM line_authors + WHERE line_id IN (SELECT id FROM lines WHERE quote_id = $1) + "#, + ) + .bind(self.id) + .execute(&mut *conn) + .await?; + + sqlx::query("DELETE FROM lines WHERE quote_id = $1") + .bind(self.id) + .execute(&mut *conn) + .await?; + + sqlx::query("DELETE FROM quote_tags WHERE quote_id = $1") + .bind(self.id) + .execute(&mut *conn) + .await?; + + sqlx::query("DELETE FROM user_quote_likes WHERE quote_id = $1") + .bind(self.id) + .execute(&mut *conn) + .await?; + + sqlx::query("DELETE FROM quotes WHERE id = $1") + .bind(self.id) + .execute(&mut *conn) + .await?; + + Ok(()) + } } impl From for QuoteError { diff --git a/src/users/permissions.rs b/src/users/permissions.rs index 0b8b5ea..78ff463 100644 --- a/src/users/permissions.rs +++ b/src/users/permissions.rs @@ -16,6 +16,8 @@ pub enum Permission { CreateTags, RenameTags, DeleteTags, + #[allow(unused)] + DeleteQuotes, ChangePersonPrimaryName, #[allow(unused)] BrowseServerLogs, diff --git a/src/web/icons/mod.rs b/src/web/icons/mod.rs index e783d90..1409e5a 100644 --- a/src/web/icons/mod.rs +++ b/src/web/icons/mod.rs @@ -26,6 +26,7 @@ pub const SERVER: &str = include_str!("server.svg"); pub const SETTINGS: &str = include_str!("settings.svg"); pub const SHIELD_USER: &str = include_str!("shield-user.svg"); pub const TAG: &str = include_str!("tag.svg"); +pub const TRASH: &str = include_str!("trash.svg"); pub const TYPE: &str = include_str!("type.svg"); pub const USER: &str = include_str!("user.svg"); pub const USER_KEY: &str = include_str!("user-key.svg"); diff --git a/src/web/icons/trash.svg b/src/web/icons/trash.svg new file mode 100644 index 0000000..9e3fe41 --- /dev/null +++ b/src/web/icons/trash.svg @@ -0,0 +1 @@ + diff --git a/src/web/pages/mod.rs b/src/web/pages/mod.rs index fbc0e0a..503e241 100644 --- a/src/web/pages/mod.rs +++ b/src/web/pages/mod.rs @@ -51,6 +51,7 @@ pub fn pages() -> Router { // .route("/quotes", get(quotes::page)) .route("/quotes/{id}", get(quotes::id::page)) + .route("/quotes/{id}/delete", post(quotes::id::delete)) .route("/quotes/add", get(quotes::add::page)) .route("/quotes/add-form", post(quotes::add::form)) // diff --git a/src/web/pages/quotes/id.rs b/src/web/pages/quotes/id.rs index 1deb7d0..0bc3354 100644 --- a/src/web/pages/quotes/id.rs +++ b/src/web/pages/quotes/id.rs @@ -1,5 +1,6 @@ use axum::{ extract::{Path, Request, State}, + http::HeaderMap, response::{IntoResponse, Redirect, Response}, }; use maud::{PreEscaped, html}; @@ -8,8 +9,12 @@ use uuid::Uuid; use crate::{ MnemoState, error::CompositeError, + logs::{LogAction, LogEntry}, quotes::Quote, - users::{User, auth::UserAuthenticate}, + users::{ + User, + auth::{UserAuthRequired, UserAuthenticate}, + }, web::{ components::{nav::nav, quote::quote}, icons, @@ -43,6 +48,18 @@ pub async fn page( } @if let Ok(q) = q { (quote(&q)) + div class="flex flex-row w-full flex-wrap justify-end gap-2 mt-2" { + a href="#" disabled class="opacity-[.5] cursor-not-allowed px-2 py-1 border rounded flex flex-row gap-1 bg-neutral-200/5 border-neutral-200/25 hover:bg-neutral-200/15 hover:border-neutral-200/45" { + span class="scale-[.75]" {(PreEscaped(icons::PEN))} + "Edit" + } + form method="post" action=(format!("/quotes/{id}/delete")) { + button type="submit" class="px-2 py-1 border rounded flex flex-row gap-1 bg-pink-400/10 border-pink-400/25 hover:bg-pink-400/20 hover:border-pink-400/45" { + span class="scale-[.75]" {(PreEscaped(icons::TRASH))} + "Delete" + } + } + } } @else { "Failed to fetch quote. Are you sure it exists?" } @@ -51,3 +68,19 @@ pub async fn page( ) .into_response()) } + +pub async fn delete( + State(state): State, + Path(id): Path, + headers: HeaderMap, +) -> Result { + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut *tx, &headers).await?.required()?; + + let q = Quote::get_by_id(&mut *tx, id).await?; + LogEntry::new(&mut *tx, u, LogAction::DeleteQuote { quote: q.clone() }).await?; + q.delete(&mut tx).await?; + tx.commit().await?; + + Ok(Redirect::to("/quotes").into_response()) +}