web UI, tailwind, icons, login

This commit is contained in:
2026-03-08 11:31:32 +01:00
parent 149bf43c01
commit e1578af68e
27 changed files with 462 additions and 10 deletions

View 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
View 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
View 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
View 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)
}
)
}