web UI, tailwind, icons, login
98
build.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
println!("cargo:rerun-if-changed=src/web");
|
||||||
|
|
||||||
|
if std::env::var("IN_DOCKER").is_err() {
|
||||||
|
let os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_else(|_| String::from("unknown"));
|
||||||
|
let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_else(|_| String::from("unknown"));
|
||||||
|
let download_url = match (os.as_str(), arch.as_str()) {
|
||||||
|
("macos", "aarch64") => {
|
||||||
|
"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64"
|
||||||
|
}
|
||||||
|
("linux", "x86_64") => {
|
||||||
|
"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64"
|
||||||
|
}
|
||||||
|
("linux", "aarch64") => {
|
||||||
|
"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-arm64"
|
||||||
|
}
|
||||||
|
_ => return Err(format!("Unsupported platform: {} {}", os, arch).into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
|
||||||
|
let tailwind_binary = out_dir.join("tailwind");
|
||||||
|
fs::create_dir_all(&out_dir)?;
|
||||||
|
|
||||||
|
download_tailwind(&download_url, &tailwind_binary)?;
|
||||||
|
println!("cargo:rustc-env=TAILWIND_BIN={}", tailwind_binary.display());
|
||||||
|
run_tailwind(&tailwind_binary)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_tailwind(bin: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
println!("Building CSS with Tailwind...");
|
||||||
|
|
||||||
|
let basedir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
|
||||||
|
let input = Path::new(&basedir)
|
||||||
|
.join("src")
|
||||||
|
.join("web")
|
||||||
|
.join("input.css");
|
||||||
|
let inputstr = input.to_str().unwrap();
|
||||||
|
let output = Path::new(&basedir)
|
||||||
|
.join("src")
|
||||||
|
.join("web")
|
||||||
|
.join("styles.css");
|
||||||
|
let outputstr = output.to_str().unwrap();
|
||||||
|
let args = vec!["-i", inputstr, "-o", outputstr, "--minify"];
|
||||||
|
|
||||||
|
let run = Command::new(&bin).args(args).status()?;
|
||||||
|
match run.success() {
|
||||||
|
true => println!("Tailwind CSS build complete."),
|
||||||
|
false => println!("Tailwind CSS build failed."),
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_tailwind(url: &str, target_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if target_path.exists() {
|
||||||
|
return Ok(()); // if present, assume the file is the appropriate tailwind binary
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmp_download = target_path.with_extension("tmp");
|
||||||
|
let download_status = if Command::new("curl").arg("--version").output().is_ok() {
|
||||||
|
Command::new("curl")
|
||||||
|
.arg("-L") // Follow redirects
|
||||||
|
.arg("-o")
|
||||||
|
.arg(&tmp_download)
|
||||||
|
.arg(url)
|
||||||
|
.status()?
|
||||||
|
} else if Command::new("wget").arg("--version").output().is_ok() {
|
||||||
|
Command::new("wget")
|
||||||
|
.arg("-O")
|
||||||
|
.arg(&tmp_download)
|
||||||
|
.arg(url)
|
||||||
|
.status()?
|
||||||
|
} else {
|
||||||
|
return Err("Neither curl nor wget is available".into());
|
||||||
|
};
|
||||||
|
|
||||||
|
if !download_status.success() {
|
||||||
|
return Err(format!("Tailwind binary download failed: {}", download_status).into());
|
||||||
|
}
|
||||||
|
fs::rename(&tmp_download, target_path)?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = fs::metadata(target_path)?.permissions();
|
||||||
|
perms.set_mode(0o777);
|
||||||
|
fs::set_permissions(target_path, perms)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Form, Json,
|
||||||
http::{HeaderMap, header},
|
http::{HeaderMap, header},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -20,10 +20,9 @@ pub struct LoginForm {
|
|||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(Json(creds): Json<LoginForm>) -> Result<Response, AuthError> {
|
fn login_common(creds: LoginForm) -> Result<(String, String), AuthError> {
|
||||||
let u = authenticate_via_credentials(&creds.handle, &creds.password)?.required()?;
|
let u = authenticate_via_credentials(&creds.handle, &creds.password)?.required()?;
|
||||||
let (_, token) = Session::new_for_user(&u)?;
|
let (_, token) = Session::new_for_user(&u)?;
|
||||||
|
|
||||||
let secure = match cfg!(debug_assertions) {
|
let secure = match cfg!(debug_assertions) {
|
||||||
false => "; Secure",
|
false => "; Secure",
|
||||||
true => "",
|
true => "",
|
||||||
@@ -33,9 +32,20 @@ pub async fn login(Json(creds): Json<LoginForm>) -> Result<Response, AuthError>
|
|||||||
Session::DEFAULT_PROLONGATION.num_seconds(),
|
Session::DEFAULT_PROLONGATION.num_seconds(),
|
||||||
secure
|
secure
|
||||||
);
|
);
|
||||||
|
Ok((token, cookie))
|
||||||
|
}
|
||||||
|
pub async fn login(Json(creds): Json<LoginForm>) -> Result<Response, AuthError> {
|
||||||
|
let (token, cookie) = login_common(creds)?;
|
||||||
Ok(([(header::SET_COOKIE, cookie)], token).into_response())
|
Ok(([(header::SET_COOKIE, cookie)], token).into_response())
|
||||||
}
|
}
|
||||||
|
pub async fn login_form(Form(creds): Form<LoginForm>) -> Result<Response, AuthError> {
|
||||||
|
match login_common(creds) {
|
||||||
|
Ok((_, cookie)) => {
|
||||||
|
Ok(([(header::SET_COOKIE, cookie)], Redirect::to("/dashboard")).into_response())
|
||||||
|
}
|
||||||
|
Err(e) => Ok(Redirect::to(&format!("/login?msg={}", e.to_string())).into_response()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn logout(headers: HeaderMap) -> Result<Response, AuthError> {
|
pub async fn logout(headers: HeaderMap) -> Result<Response, AuthError> {
|
||||||
let mut s = Session::authenticate(&headers)?.required()?;
|
let mut s = Session::authenticate(&headers)?.required()?;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub fn api_router() -> Router {
|
|||||||
.route("/api/live", get(async || "Mnemosyne lives"))
|
.route("/api/live", get(async || "Mnemosyne lives"))
|
||||||
// auth
|
// auth
|
||||||
.route("/api/auth/login", post(auth::login))
|
.route("/api/auth/login", post(auth::login))
|
||||||
|
.route("/api/auth/login-form", post(auth::login_form))
|
||||||
.route("/api/auth/logout", post(auth::logout))
|
.route("/api/auth/logout", post(auth::logout))
|
||||||
// users
|
// users
|
||||||
.route("/api/users", get(users::get_all))
|
.route("/api/users", get(users::get_all))
|
||||||
|
|||||||
@@ -3,6 +3,19 @@ use std::io::{self, Write};
|
|||||||
use env_logger::fmt::Formatter;
|
use env_logger::fmt::Formatter;
|
||||||
use log::Record;
|
use log::Record;
|
||||||
|
|
||||||
|
pub const REFERENCE_SPLASHES: &[&str] = &[
|
||||||
|
"quote engine",
|
||||||
|
"powered by rust",
|
||||||
|
"made in poznań",
|
||||||
|
"blazingly fast",
|
||||||
|
"always be kind",
|
||||||
|
"as seen on localhost",
|
||||||
|
"now with extra lifetimes",
|
||||||
|
"memory palace",
|
||||||
|
"take a break sometimes",
|
||||||
|
"segmentation fault (jk)",
|
||||||
|
];
|
||||||
|
|
||||||
pub fn envlogger_write_format(buf: &mut Formatter, rec: &Record) -> io::Result<()> {
|
pub fn envlogger_write_format(buf: &mut Formatter, rec: &Record) -> io::Result<()> {
|
||||||
let level_string = format!("{}", rec.level());
|
let level_string = format!("{}", rec.level());
|
||||||
let level_style = buf.default_level_style(rec.level());
|
let level_style = buf.default_level_style(rec.level());
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
@@ -9,6 +10,7 @@ mod persons;
|
|||||||
mod quotes;
|
mod quotes;
|
||||||
mod tags;
|
mod tags;
|
||||||
mod users;
|
mod users;
|
||||||
|
mod web;
|
||||||
|
|
||||||
/// Mnemosyne, the mother of the nine muses
|
/// Mnemosyne, the mother of the nine muses
|
||||||
const DEFAULT_PORT: u16 = 0x9999; // 39321
|
const DEFAULT_PORT: u16 = 0x9999; // 39321
|
||||||
@@ -40,7 +42,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
_ => return Err(e)?,
|
_ => return Err(e)?,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let r = api::api_router();
|
let r = Router::new()
|
||||||
|
.merge(api::api_router())
|
||||||
|
.merge(web::web_router());
|
||||||
let l = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
|
let l = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
|
||||||
log::info!("Listener bound to {}", l.local_addr()?);
|
log::info!("Listener bound to {}", l.local_addr()?);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
http::StatusCode,
|
http::{StatusCode, header},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
@@ -10,7 +10,10 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
database::{self, DatabaseError},
|
database::{self, DatabaseError},
|
||||||
users::{User, auth},
|
users::{
|
||||||
|
User,
|
||||||
|
auth::{self, COOKIE_NAME},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -40,7 +43,7 @@ pub enum SessionError {
|
|||||||
DatabaseError(#[from] DatabaseError),
|
DatabaseError(#[from] DatabaseError),
|
||||||
#[error("No session found with id: {0}")]
|
#[error("No session found with id: {0}")]
|
||||||
NoSessionWithId(Uuid),
|
NoSessionWithId(Uuid),
|
||||||
#[error("No session found with token: {0}")]
|
#[error("No session found with provided token")]
|
||||||
NoSessionWithToken(String),
|
NoSessionWithToken(String),
|
||||||
}
|
}
|
||||||
impl From<rusqlite::Error> for SessionError {
|
impl From<rusqlite::Error> for SessionError {
|
||||||
@@ -54,7 +57,13 @@ impl IntoResponse for SessionError {
|
|||||||
Self::DatabaseError(e) => e.into_response(),
|
Self::DatabaseError(e) => e.into_response(),
|
||||||
Self::NoSessionWithId(_) => (StatusCode::BAD_REQUEST, self.to_string()).into_response(),
|
Self::NoSessionWithId(_) => (StatusCode::BAD_REQUEST, self.to_string()).into_response(),
|
||||||
Self::NoSessionWithToken(_) => {
|
Self::NoSessionWithToken(_) => {
|
||||||
(StatusCode::BAD_REQUEST, self.to_string()).into_response()
|
let cookie = format!("{COOKIE_NAME}=revoking; Path=/; HttpOnly; Max-Age=0");
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
[(header::SET_COOKIE, cookie)],
|
||||||
|
self.to_string(),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/web/components/marquee.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use maud::{Markup, html};
|
||||||
|
|
||||||
|
const SPAN_CLASS: &str = "shrink-0 text-[10px] uppercase tracking-[0.3em] text-neutral-500/40";
|
||||||
|
const MIN_WORDS: usize = 32;
|
||||||
|
const COPIES: usize = 4;
|
||||||
|
|
||||||
|
pub fn marquee(words: &[&str]) -> Markup {
|
||||||
|
let filled = fill_words(words);
|
||||||
|
html!(
|
||||||
|
div class="overflow-hidden font-lexend font-light select-none border-y border-neutral-500/20 py-3" aria-hidden="true" {
|
||||||
|
div class="flex" {
|
||||||
|
@for _copy in 0..COPIES {
|
||||||
|
div class="animate-marquee flex shrink-0 gap-8 pr-8" {
|
||||||
|
@for word in &filled {
|
||||||
|
span class=(SPAN_CLASS) { (word) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_words(words: &[&str]) -> Vec<String> {
|
||||||
|
if words.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let reps = (MIN_WORDS.div_ceil(words.len())).max(1);
|
||||||
|
words
|
||||||
|
.iter()
|
||||||
|
.cycle()
|
||||||
|
.take(words.len() * reps)
|
||||||
|
.map(|w| w.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
2
src/web/components/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod marquee;
|
||||||
|
pub mod nav;
|
||||||
50
src/web/components/nav.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use maud::{Markup, PreEscaped, html};
|
||||||
|
|
||||||
|
use crate::{users::User, web::icons};
|
||||||
|
|
||||||
|
const LINKS: &[(&str, &str, &str)] = &[
|
||||||
|
("Dashboard", "/dashboard", icons::LAYOUT_DASHBOARD),
|
||||||
|
("Quotes", "#quotes", icons::SCROLL_TEXT),
|
||||||
|
("Photos", "#photos", icons::FILE_IMAGE),
|
||||||
|
("Persons", "#persons", icons::CONTACT),
|
||||||
|
("Tags", "#tags", icons::TAG),
|
||||||
|
("Users", "#users", icons::USERS),
|
||||||
|
("Logs", "#logs", icons::CLIPBOARD_CLOCK),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn nav(user: Option<User>, uri: &str) -> Markup {
|
||||||
|
html!(
|
||||||
|
div class="flex items-center text-sm gap-4 border-b border-neutral-200/25 bg-neutral-200/5 px-4 py-2" {
|
||||||
|
a href="/dashboard" class="font-lora font-semibold text-xl mr-2" {"Mnemosyne"}
|
||||||
|
div class="w-px h-5 bg-neutral-200/15" {}
|
||||||
|
div class="flex flex-row" {
|
||||||
|
@for link in LINKS {
|
||||||
|
a href={(link.1)} class="flex flex-row px-2 py-1 rounded items-center gap-2 hover:bg-neutral-200/5 border border-transparent hover:border-neutral-200/25" {
|
||||||
|
@if uri.starts_with(link.1) {
|
||||||
|
div class="scale-[.75] text-neutral-300" {(PreEscaped(link.2))}
|
||||||
|
span class="text-neutral-200 font-light" { (link.0) }
|
||||||
|
} @else {
|
||||||
|
div class="scale-[.75] text-neutral-500" {(PreEscaped(link.2))}
|
||||||
|
span class="text-neutral-400 font-light" { (link.0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@if let Some(u) = user {
|
||||||
|
a href="/dashboard" class=r#"ml-auto bg-neutral-200/5 font-lexend flex
|
||||||
|
flex-row items-center border border-neutral-200/25 gap-4 rounded px-2 py-1"# {
|
||||||
|
(u.handle)
|
||||||
|
div class="scale-[.75]" {(PreEscaped(icons::USER))}
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
a href="/login" class=r#"ml-auto bg-neutral-200/5 font-lexend flex
|
||||||
|
flex-row items-center border border-neutral-200/25 gap-4 rounded px-2 py-1"# {
|
||||||
|
"Log in"
|
||||||
|
div class="scale-[.75]" {(PreEscaped(icons::USER_KEY))}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/web/icons/arrow-right.svg
Normal 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-arrow-right-icon lucide-arrow-right"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||||
|
After Width: | Height: | Size: 291 B |
1
src/web/icons/clipboard-clock.svg
Normal 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-clipboard-clock-icon lucide-clipboard-clock"><path d="M16 14v2.2l1.6 1"/><path d="M16 4h2a2 2 0 0 1 2 2v.832"/><path d="M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h2"/><circle cx="16" cy="16" r="6"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg>
|
||||||
|
After Width: | Height: | Size: 449 B |
1
src/web/icons/contact.svg
Normal 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-contact-icon lucide-contact"><path d="M16 2v2"/><path d="M7 22v-2a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2"/><path d="M8 2v2"/><circle cx="12" cy="11" r="3"/><rect x="3" y="4" width="18" height="18" rx="2"/></svg>
|
||||||
|
After Width: | Height: | Size: 407 B |
1
src/web/icons/file-image.svg
Normal 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-file-image-icon lucide-file-image"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><circle cx="10" cy="12" r="2"/><path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22"/></svg>
|
||||||
|
After Width: | Height: | Size: 491 B |
1
src/web/icons/layout-dashboard.svg
Normal 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-layout-dashboard-icon lucide-layout-dashboard"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>
|
||||||
|
After Width: | Height: | Size: 448 B |
11
src/web/icons/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Below icons sourced from https://lucide.dev
|
||||||
|
pub const ARROW_RIGHT: &str = include_str!("arrow-right.svg");
|
||||||
|
pub const CLIPBOARD_CLOCK: &str = include_str!("clipboard-clock.svg");
|
||||||
|
pub const CONTACT: &str = include_str!("contact.svg");
|
||||||
|
pub const FILE_IMAGE: &str = include_str!("file-image.svg");
|
||||||
|
pub const LAYOUT_DASHBOARD: &str = include_str!("layout-dashboard.svg");
|
||||||
|
pub const SCROLL_TEXT: &str = include_str!("scroll-text.svg");
|
||||||
|
pub const TAG: &str = include_str!("tag.svg");
|
||||||
|
pub const USER: &str = include_str!("user.svg");
|
||||||
|
pub const USER_KEY: &str = include_str!("user-key.svg");
|
||||||
|
pub const USERS: &str = include_str!("users.svg");
|
||||||
1
src/web/icons/scroll-text.svg
Normal 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-scroll-text-icon lucide-scroll-text"><path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/></svg>
|
||||||
|
After Width: | Height: | Size: 441 B |
1
src/web/icons/tag.svg
Normal 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-tag-icon lucide-tag"><path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/></svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
1
src/web/icons/user-key.svg
Normal 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-user-key-icon lucide-user-key"><path d="M20 11v6"/><path d="M20 13h2"/><path d="M3 21v-2a4 4 0 0 1 4-4h6a4 4 0 0 1 2.072.578"/><circle cx="10" cy="7" r="4"/><circle cx="20" cy="19" r="2"/></svg>
|
||||||
|
After Width: | Height: | Size: 397 B |
1
src/web/icons/user.svg
Normal 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-user-icon lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
After Width: | Height: | Size: 315 B |
1
src/web/icons/users.svg
Normal 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-users-icon lucide-users"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/></svg>
|
||||||
|
After Width: | Height: | Size: 393 B |
26
src/web/input.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-chocolatebg: oklch(0.2 0.01 50);
|
||||||
|
--color-offwhitebg: oklch(0.97 0.005 75);
|
||||||
|
|
||||||
|
--font-lora: "Lora", ui-serif, Georgia, Cambria, "Times New Roman", serif;
|
||||||
|
--font-lexend: "Lexend", sans-serif;
|
||||||
|
|
||||||
|
--animate-marquee: marquee 180s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marquee {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-marquee {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/web/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use axum::Router;
|
||||||
|
use tower_http::services::ServeFile;
|
||||||
|
|
||||||
|
mod components;
|
||||||
|
mod icons;
|
||||||
|
mod pages;
|
||||||
|
|
||||||
|
pub fn web_router() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route_service("/styles.css", ServeFile::new("src/web/styles.css"))
|
||||||
|
.merge(pages::pages())
|
||||||
|
}
|
||||||
19
src/web/pages/dashboard.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use axum::extract::Request;
|
||||||
|
use maud::{Markup, html};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
users::{User, auth::UserAuthenticate},
|
||||||
|
web::{components::nav::nav, pages::base},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn page(req: Request) -> Markup {
|
||||||
|
let u = User::authenticate(req.headers()).ok().flatten();
|
||||||
|
base(
|
||||||
|
"Dashboard | Mnemosyne",
|
||||||
|
html!(
|
||||||
|
(nav(u, req.uri().path()))
|
||||||
|
|
||||||
|
div class="text-8xl text-neutral-800/25 mt-16 text-center font-semibold font-lora select-none" {"Mnemosyne"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/web/pages/index.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
|
|
||||||
|
use crate::users::auth::AuthError;
|
||||||
|
|
||||||
|
pub async fn page() -> Result<Response, AuthError> {
|
||||||
|
Ok(Redirect::to("/dashboard").into_response())
|
||||||
|
}
|
||||||
109
src/web/pages/login.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Query, Request},
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
use maud::{PreEscaped, html};
|
||||||
|
use rand::seq::IndexedRandom;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::REFERENCE_SPLASHES,
|
||||||
|
users::{
|
||||||
|
User,
|
||||||
|
auth::{AuthError, UserAuthenticate},
|
||||||
|
},
|
||||||
|
web::{components::marquee::marquee, icons, pages::base},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginMsg {
|
||||||
|
msg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn page(Query(q): Query<LoginMsg>, req: Request) -> Result<Response, AuthError> {
|
||||||
|
let u = User::authenticate(req.headers())?;
|
||||||
|
if u.is_some() {
|
||||||
|
return Ok(Redirect::to("/dashboard").into_response());
|
||||||
|
}
|
||||||
|
Ok(base(
|
||||||
|
"Log in | Mnemosyne",
|
||||||
|
html!(
|
||||||
|
div class="min-h-screen flex flex-col" {
|
||||||
|
(marquee(&["the goddess of memory"]))
|
||||||
|
|
||||||
|
div class="mt-24" {}
|
||||||
|
a href="/" class="font-semibold text-6xl mx-auto font-lora hover:underline" {h1 {"Mnemosyne"}}
|
||||||
|
p class="text-neutral-500 mt-4 text-sm mx-auto mb-16" {"The goddess of memory holds all the cards."}
|
||||||
|
|
||||||
|
div class="mx-auto bg-neutral-200/5 border border-neutral-200/25 p-4 rounded" {
|
||||||
|
p class="text-neutral-500" {"Part of the olympic pack already? Log in here."}
|
||||||
|
|
||||||
|
form id="login-form" method="post" action="/api/auth/login-form" class="mt-8 font-light flex flex-col" {
|
||||||
|
label for="handle" class="text-neutral-500" {"Handle"}
|
||||||
|
div class="flex items-center w-full border border-neutral-200/25 rounded bg-neutral-950/50" {
|
||||||
|
span class="pl-2 text-neutral-500 select-none" {"@"}
|
||||||
|
input id="handle" name="handle" type="text"
|
||||||
|
class="w-full bg-transparent pl-[2px] pr-1 py-1 outline-none";
|
||||||
|
}
|
||||||
|
|
||||||
|
label for="password" class="text-neutral-500 font-light mt-2" {"Password"}
|
||||||
|
input id="password" name="password" type="password"
|
||||||
|
class="w-full border border-neutral-200/25 px-2 py-1 rounded bg-neutral-950/50";
|
||||||
|
|
||||||
|
div class="flex flex-row items-center justify-between mt-4" {
|
||||||
|
@if let Some(msg) = q.msg {
|
||||||
|
p id="login-error" class="text-red-400 text-sm" {
|
||||||
|
(msg)
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
p id="login-error" class="text-red-400 text-sm" {}
|
||||||
|
}
|
||||||
|
button type="submit" class=r#"block ml-auto border border-neutral-200/25 font-normal px-2 py-1
|
||||||
|
rounded bg-neutral-200/5 flex gap-4 justify-between cursor-pointer"# {
|
||||||
|
"Log in"
|
||||||
|
(PreEscaped(icons::ARROW_RIGHT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Logging in is done via JavaScript here by default to preserve
|
||||||
|
// marquee scroll on fail (form-based would reset it on fail)
|
||||||
|
// (if javascript is disabled, login via form still works)
|
||||||
|
script defer {(PreEscaped(r#"
|
||||||
|
if (window.location.search) {
|
||||||
|
history.replaceState(null, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const err = document.getElementById('login-error');
|
||||||
|
err.textContent = '';
|
||||||
|
|
||||||
|
const handle = document.getElementById('handle').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ handle, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
} else {
|
||||||
|
const text = await res.text();
|
||||||
|
err.textContent = text || 'Login failed';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
err.textContent = 'Network error — please try again';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
"#
|
||||||
|
))}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="mt-auto" {}
|
||||||
|
(marquee(&[REFERENCE_SPLASHES.choose(&mut rand::rng()).unwrap()]))
|
||||||
|
}
|
||||||
|
),
|
||||||
|
).into_response())
|
||||||
|
}
|
||||||
34
src/web/pages/mod.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use axum::{Router, routing::get};
|
||||||
|
use maud::{DOCTYPE, Markup, html};
|
||||||
|
|
||||||
|
pub mod dashboard;
|
||||||
|
pub mod index;
|
||||||
|
pub mod login;
|
||||||
|
|
||||||
|
pub fn pages() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(index::page))
|
||||||
|
.route("/login", get(login::page))
|
||||||
|
.route("/dashboard", get(dashboard::page))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base(title: &str, inner: Markup) -> Markup {
|
||||||
|
html!(
|
||||||
|
(DOCTYPE)
|
||||||
|
head {
|
||||||
|
title {(title)}
|
||||||
|
meta charset="utf-8";
|
||||||
|
link rel="stylesheet" href="/styles.css";
|
||||||
|
// link rel="icon" type="image/png" href="/icon.png";
|
||||||
|
// link rel="icon" type="image/svg" href="/icon.svg";
|
||||||
|
meta name="viewport" content="width=device-width, initial-scale=1.0";
|
||||||
|
|
||||||
|
link rel="preconnect" href="https://fonts.googleapis.com";
|
||||||
|
link rel="preconnect" href="https://fonts.gstatic.com" crossorigin;
|
||||||
|
link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Lexend:wght@100..900&family=Lora:ital,wght@0,400..700;1,400..700&display=swap";
|
||||||
|
}
|
||||||
|
body class="bg-neutral-900 text-neutral-200 font-lexend min-h-screen" {
|
||||||
|
(inner)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||