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

98
build.rs Normal file
View 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(())
}

View File

@@ -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()?;

View File

@@ -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))

View File

@@ -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());

View File

@@ -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()?);

View File

@@ -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()
}
}
}

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

View File

@@ -0,0 +1,2 @@
pub mod marquee;
pub mod nav;

50
src/web/components/nav.rs Normal file
View 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))}
}
}
}
)
}

View 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

View 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

View 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

View 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

View 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
View 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");

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

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

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

2
src/web/styles.css Normal file

File diff suppressed because one or more lines are too long