use axum::{ extract::{Request, State}, http::HeaderMap, response::{IntoResponse, Redirect, Response}, }; use axum_extra::extract::Form; use chrono::NaiveDateTime; use maud::{Markup, PreEscaped, html}; use reqwest::Url; use serde::Deserialize; use uuid::Uuid; use crate::{ MnemoState, error::CompositeError, logs::{LogAction, LogEntry}, persons::Name, quotes::Quote, users::{ User, auth::{UserAuthRequired, UserAuthenticate}, }, web::{components::nav::nav, icons, pages::base}, }; const LINE_ADD_RM_SCRIPT: &str = include_str!("line-add-rm.js"); const PREFILL_TIME_SCRIPT: &str = include_str!("prefill-time.js"); pub async fn page( State(state): State, 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 names = Name::get_all(&mut *conn).await?; Ok(base( "Add Quote | Mnemosyne", html!( (nav(Some(&u), 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" { div quotelines class="flex flex-col" { @for i in 1..=3 {(maker_line_row(i==1, &names))} } template quotelinetemplate { (maker_line_row(false, &names)) } div class="flex flex-row gap-2" { hr class="border-neutral-200/25 flex-1 my-4"; button addlinebtn type="button" class="w-fit text-neutral-400 hover:text-neutral-300 cursor-pointer" { "Add line" } } script {(PreEscaped(LINE_ADD_RM_SCRIPT))} script {(PreEscaped(PREFILL_TIME_SCRIPT))} 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()) } fn maker_line_row(rm_disabled: bool, names: &[Name]) -> Markup { html!( div quoteline class="flex gap-4" { div class="flex flex-col flex-1" { label class="w-full" { p class="mb-1" {"Quote line"} input type="text" name="quoteline" placeholder="They said..." autocomplete="off" required class="px-2 py-1 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25"; } } div class="flex flex-col ml-auto" { label { p class="mb-1" {"Attribution"} select name="quoteauthor" autocomplete="off" required class="px-2 py-1.5 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25"{ option value="" {"--"} @for name in names { option value=(name.id.to_string()) {(name.name)} } } } } button rmlinebtn disabled?[rm_disabled] type="button" class="h-fit mt-auto mb-2 p-1 bg-neutral-200/5 hover:bg-neutral-200/15 rounded border border-neutral-200/25 hover:border-neutral-200/45 cursor-pointer disabled:cursor-not-allowed disabled:opacity-[.5]" { (PreEscaped(icons::CIRCLE_MINUS)) } } ) } #[derive(Deserialize, Debug)] pub struct IncomingQuote { #[serde(rename = "quoteline")] lines: Vec, #[serde(rename = "quoteauthor")] authors: Vec, location: String, time: String, context: String, } pub async fn form( State(state): State, headers: HeaderMap, Form(form): Form, ) -> Result { let mut tx = state.pool.begin().await?; let u = User::authenticate(&mut *tx, &headers).await?.required()?; let mut authors = Vec::new(); for nid in form.authors { authors.push(Name::get_by_id(&mut *tx, nid).await.unwrap()); } let lines = form .lines .into_iter() .zip(authors) .map(|(l, a)| (l, vec![a])) .collect(); let timestamp = match NaiveDateTime::parse_from_str(&form.time, "%Y-%m-%dT%H:%M") { Ok(ts) => ts, Err(_) => return Ok("Time was formatted wrong.".into_response()), }; 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(&mut *tx, lines, timestamp, context, location, u.id, false).await?; LogEntry::new(&mut *tx, u, LogAction::CreateQuote { id: q.id }).await?; tx.commit().await?; if let Ok(webhook_url) = std::env::var("DISCORD_WEBHOOK_URL") { match Url::parse(&webhook_url) { Ok(u) => q.post_msg_webhook(u), Err(e) => log::error!("Tried to post webhook, failed to parse url: {e}"), } } Ok(Redirect::to("/dashboard").into_response()) }