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::{
|
||||
Json,
|
||||
Form, Json,
|
||||
http::{HeaderMap, header},
|
||||
response::{IntoResponse, Response},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -20,10 +20,9 @@ pub struct LoginForm {
|
||||
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 (_, token) = Session::new_for_user(&u)?;
|
||||
|
||||
let secure = match cfg!(debug_assertions) {
|
||||
false => "; Secure",
|
||||
true => "",
|
||||
@@ -33,9 +32,20 @@ pub async fn login(Json(creds): Json<LoginForm>) -> Result<Response, AuthError>
|
||||
Session::DEFAULT_PROLONGATION.num_seconds(),
|
||||
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())
|
||||
}
|
||||
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> {
|
||||
let mut s = Session::authenticate(&headers)?.required()?;
|
||||
|
||||
@@ -24,6 +24,7 @@ pub fn api_router() -> Router {
|
||||
.route("/api/live", get(async || "Mnemosyne lives"))
|
||||
// auth
|
||||
.route("/api/auth/login", post(auth::login))
|
||||
.route("/api/auth/login-form", post(auth::login_form))
|
||||
.route("/api/auth/logout", post(auth::logout))
|
||||
// users
|
||||
.route("/api/users", get(users::get_all))
|
||||
|
||||
@@ -3,6 +3,19 @@ use std::io::{self, Write};
|
||||
use env_logger::fmt::Formatter;
|
||||
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<()> {
|
||||
let level_string = format!("{}", rec.level());
|
||||
let level_style = buf.default_level_style(rec.level());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::error::Error;
|
||||
|
||||
use axum::Router;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
mod api;
|
||||
@@ -9,6 +10,7 @@ mod persons;
|
||||
mod quotes;
|
||||
mod tags;
|
||||
mod users;
|
||||
mod web;
|
||||
|
||||
/// Mnemosyne, the mother of the nine muses
|
||||
const DEFAULT_PORT: u16 = 0x9999; // 39321
|
||||
@@ -40,7 +42,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
_ => 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?;
|
||||
log::info!("Listener bound to {}", l.local_addr()?);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
@@ -10,7 +10,10 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
database::{self, DatabaseError},
|
||||
users::{User, auth},
|
||||
users::{
|
||||
User,
|
||||
auth::{self, COOKIE_NAME},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -40,7 +43,7 @@ pub enum SessionError {
|
||||
DatabaseError(#[from] DatabaseError),
|
||||
#[error("No session found with id: {0}")]
|
||||
NoSessionWithId(Uuid),
|
||||
#[error("No session found with token: {0}")]
|
||||
#[error("No session found with provided token")]
|
||||
NoSessionWithToken(String),
|
||||
}
|
||||
impl From<rusqlite::Error> for SessionError {
|
||||
@@ -54,7 +57,13 @@ impl IntoResponse for SessionError {
|
||||
Self::DatabaseError(e) => e.into_response(),
|
||||
Self::NoSessionWithId(_) => (StatusCode::BAD_REQUEST, self.to_string()).into_response(),
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||