From e1578af68e438308f5874ab4fe5f405fb8d07412 Mon Sep 17 00:00:00 2001 From: jakubmanczak Date: Sun, 8 Mar 2026 11:31:32 +0100 Subject: [PATCH] web UI, tailwind, icons, login --- build.rs | 98 ++++++++++++++++++++++++++ src/api/auth.rs | 20 ++++-- src/api/mod.rs | 1 + src/config.rs | 13 ++++ src/main.rs | 6 +- src/users/sessions.rs | 17 +++-- src/web/components/marquee.rs | 35 +++++++++ src/web/components/mod.rs | 2 + src/web/components/nav.rs | 50 +++++++++++++ src/web/icons/arrow-right.svg | 1 + src/web/icons/clipboard-clock.svg | 1 + src/web/icons/contact.svg | 1 + src/web/icons/file-image.svg | 1 + src/web/icons/layout-dashboard.svg | 1 + src/web/icons/mod.rs | 11 +++ src/web/icons/scroll-text.svg | 1 + src/web/icons/tag.svg | 1 + src/web/icons/user-key.svg | 1 + src/web/icons/user.svg | 1 + src/web/icons/users.svg | 1 + src/web/input.css | 26 +++++++ src/web/mod.rs | 12 ++++ src/web/pages/dashboard.rs | 19 +++++ src/web/pages/index.rs | 7 ++ src/web/pages/login.rs | 109 +++++++++++++++++++++++++++++ src/web/pages/mod.rs | 34 +++++++++ src/web/styles.css | 2 + 27 files changed, 462 insertions(+), 10 deletions(-) create mode 100644 build.rs create mode 100644 src/web/components/marquee.rs create mode 100644 src/web/components/mod.rs create mode 100644 src/web/components/nav.rs create mode 100644 src/web/icons/arrow-right.svg create mode 100644 src/web/icons/clipboard-clock.svg create mode 100644 src/web/icons/contact.svg create mode 100644 src/web/icons/file-image.svg create mode 100644 src/web/icons/layout-dashboard.svg create mode 100644 src/web/icons/mod.rs create mode 100644 src/web/icons/scroll-text.svg create mode 100644 src/web/icons/tag.svg create mode 100644 src/web/icons/user-key.svg create mode 100644 src/web/icons/user.svg create mode 100644 src/web/icons/users.svg create mode 100644 src/web/input.css create mode 100644 src/web/mod.rs create mode 100644 src/web/pages/dashboard.rs create mode 100644 src/web/pages/index.rs create mode 100644 src/web/pages/login.rs create mode 100644 src/web/pages/mod.rs create mode 100644 src/web/styles.css diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..eb63c6a --- /dev/null +++ b/build.rs @@ -0,0 +1,98 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() -> Result<(), Box> { + 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> { + 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> { + 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(()) +} diff --git a/src/api/auth.rs b/src/api/auth.rs index 1b3f6bc..152fa55 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -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) -> Result { +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) -> Result Session::DEFAULT_PROLONGATION.num_seconds(), secure ); - + Ok((token, cookie)) +} +pub async fn login(Json(creds): Json) -> Result { + let (token, cookie) = login_common(creds)?; Ok(([(header::SET_COOKIE, cookie)], token).into_response()) } +pub async fn login_form(Form(creds): Form) -> Result { + 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 { let mut s = Session::authenticate(&headers)?.required()?; diff --git a/src/api/mod.rs b/src/api/mod.rs index 234f5fe..f483495 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -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)) diff --git a/src/config.rs b/src/config.rs index 803942f..4ca0822 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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()); diff --git a/src/main.rs b/src/main.rs index 658d31d..20a49c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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> { _ => 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()?); diff --git a/src/users/sessions.rs b/src/users/sessions.rs index 39c05c0..f1d9930 100644 --- a/src/users/sessions.rs +++ b/src/users/sessions.rs @@ -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 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() } } } diff --git a/src/web/components/marquee.rs b/src/web/components/marquee.rs new file mode 100644 index 0000000..e65d957 --- /dev/null +++ b/src/web/components/marquee.rs @@ -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 { + 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() +} diff --git a/src/web/components/mod.rs b/src/web/components/mod.rs new file mode 100644 index 0000000..a050ba9 --- /dev/null +++ b/src/web/components/mod.rs @@ -0,0 +1,2 @@ +pub mod marquee; +pub mod nav; diff --git a/src/web/components/nav.rs b/src/web/components/nav.rs new file mode 100644 index 0000000..f2d9fa8 --- /dev/null +++ b/src/web/components/nav.rs @@ -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, 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))} + } + } + } + ) +} diff --git a/src/web/icons/arrow-right.svg b/src/web/icons/arrow-right.svg new file mode 100644 index 0000000..1e82b5d --- /dev/null +++ b/src/web/icons/arrow-right.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/clipboard-clock.svg b/src/web/icons/clipboard-clock.svg new file mode 100644 index 0000000..f1d1f54 --- /dev/null +++ b/src/web/icons/clipboard-clock.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/contact.svg b/src/web/icons/contact.svg new file mode 100644 index 0000000..b2cc623 --- /dev/null +++ b/src/web/icons/contact.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/file-image.svg b/src/web/icons/file-image.svg new file mode 100644 index 0000000..c68f574 --- /dev/null +++ b/src/web/icons/file-image.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/layout-dashboard.svg b/src/web/icons/layout-dashboard.svg new file mode 100644 index 0000000..5b7c7e7 --- /dev/null +++ b/src/web/icons/layout-dashboard.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/mod.rs b/src/web/icons/mod.rs new file mode 100644 index 0000000..f11d9a3 --- /dev/null +++ b/src/web/icons/mod.rs @@ -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"); diff --git a/src/web/icons/scroll-text.svg b/src/web/icons/scroll-text.svg new file mode 100644 index 0000000..f066c8a --- /dev/null +++ b/src/web/icons/scroll-text.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/tag.svg b/src/web/icons/tag.svg new file mode 100644 index 0000000..c49ce01 --- /dev/null +++ b/src/web/icons/tag.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/user-key.svg b/src/web/icons/user-key.svg new file mode 100644 index 0000000..e171d94 --- /dev/null +++ b/src/web/icons/user-key.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/user.svg b/src/web/icons/user.svg new file mode 100644 index 0000000..9d31802 --- /dev/null +++ b/src/web/icons/user.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/users.svg b/src/web/icons/users.svg new file mode 100644 index 0000000..e06a26e --- /dev/null +++ b/src/web/icons/users.svg @@ -0,0 +1 @@ + diff --git a/src/web/input.css b/src/web/input.css new file mode 100644 index 0000000..1044cd7 --- /dev/null +++ b/src/web/input.css @@ -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; + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 0000000..ac5e349 --- /dev/null +++ b/src/web/mod.rs @@ -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()) +} diff --git a/src/web/pages/dashboard.rs b/src/web/pages/dashboard.rs new file mode 100644 index 0000000..1c1dcd9 --- /dev/null +++ b/src/web/pages/dashboard.rs @@ -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"} + ), + ) +} diff --git a/src/web/pages/index.rs b/src/web/pages/index.rs new file mode 100644 index 0000000..fc2e813 --- /dev/null +++ b/src/web/pages/index.rs @@ -0,0 +1,7 @@ +use axum::response::{IntoResponse, Redirect, Response}; + +use crate::users::auth::AuthError; + +pub async fn page() -> Result { + Ok(Redirect::to("/dashboard").into_response()) +} diff --git a/src/web/pages/login.rs b/src/web/pages/login.rs new file mode 100644 index 0000000..ffeef98 --- /dev/null +++ b/src/web/pages/login.rs @@ -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, +} + +pub async fn page(Query(q): Query, req: Request) -> Result { + 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()) +} diff --git a/src/web/pages/mod.rs b/src/web/pages/mod.rs new file mode 100644 index 0000000..dbeeda5 --- /dev/null +++ b/src/web/pages/mod.rs @@ -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) + } + ) +} diff --git a/src/web/styles.css b/src/web/styles.css new file mode 100644 index 0000000..146ab72 --- /dev/null +++ b/src/web/styles.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-400:oklch(70.4% .191 22.216);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--spacing:.25rem;--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-6xl:3.75rem;--text-6xl--line-height:1;--text-8xl:6rem;--text-8xl--line-height:1;--font-weight-light:300;--font-weight-normal:400;--font-weight-semibold:600;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-lora:"Lora", ui-serif, Georgia, Cambria, "Times New Roman", serif;--font-lexend:"Lexend", sans-serif;--animate-marquee:marquee 180s linear infinite}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.mx-auto{margin-inline:auto}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-16{margin-top:calc(var(--spacing) * 16)}.mt-24{margin-top:calc(var(--spacing) * 24)}.mt-auto{margin-top:auto}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-16{margin-bottom:calc(var(--spacing) * 16)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.h-5{height:calc(var(--spacing) * 5)}.min-h-screen{min-height:100vh}.w-full{width:100%}.w-px{width:1px}.shrink-0{flex-shrink:0}.scale-\[\.75\]{scale:.75}.animate-marquee{animation:var(--animate-marquee)}.cursor-pointer{cursor:pointer}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing) * 2)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-8{gap:calc(var(--spacing) * 8)}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-neutral-200\/25{border-color:#e5e5e540}@supports (color:color-mix(in lab, red, red)){.border-neutral-200\/25{border-color:color-mix(in oklab, var(--color-neutral-200) 25%, transparent)}}.border-neutral-500\/20{border-color:#73737333}@supports (color:color-mix(in lab, red, red)){.border-neutral-500\/20{border-color:color-mix(in oklab, var(--color-neutral-500) 20%, transparent)}}.border-transparent{border-color:#0000}.bg-neutral-200\/5{background-color:#e5e5e50d}@supports (color:color-mix(in lab, red, red)){.bg-neutral-200\/5{background-color:color-mix(in oklab, var(--color-neutral-200) 5%, transparent)}}.bg-neutral-200\/15{background-color:#e5e5e526}@supports (color:color-mix(in lab, red, red)){.bg-neutral-200\/15{background-color:color-mix(in oklab, var(--color-neutral-200) 15%, transparent)}}.bg-neutral-900{background-color:var(--color-neutral-900)}.bg-neutral-950\/50{background-color:#0a0a0a80}@supports (color:color-mix(in lab, red, red)){.bg-neutral-950\/50{background-color:color-mix(in oklab, var(--color-neutral-950) 50%, transparent)}}.bg-transparent{background-color:#0000}.p-4{padding:calc(var(--spacing) * 4)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.pr-1{padding-right:calc(var(--spacing) * 1)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-\[2px\]{padding-left:2px}.text-center{text-align:center}.font-lexend{font-family:var(--font-lexend)}.font-lora{font-family:var(--font-lora)}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-8xl{font-size:var(--text-8xl);line-height:var(--tw-leading,var(--text-8xl--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-\[10px\]{font-size:10px}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.3em\]{--tw-tracking:.3em;letter-spacing:.3em}.text-neutral-200{color:var(--color-neutral-200)}.text-neutral-300{color:var(--color-neutral-300)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-500\/40{color:#73737366}@supports (color:color-mix(in lab, red, red)){.text-neutral-500\/40{color:color-mix(in oklab, var(--color-neutral-500) 40%, transparent)}}.text-neutral-800\/25{color:#26262640}@supports (color:color-mix(in lab, red, red)){.text-neutral-800\/25{color:color-mix(in oklab, var(--color-neutral-800) 25%, transparent)}}.text-red-400{color:var(--color-red-400)}.uppercase{text-transform:uppercase}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.hover\:border-neutral-200\/25:hover{border-color:#e5e5e540}@supports (color:color-mix(in lab, red, red)){.hover\:border-neutral-200\/25:hover{border-color:color-mix(in oklab, var(--color-neutral-200) 25%, transparent)}}.hover\:bg-neutral-200\/5:hover{background-color:#e5e5e50d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-neutral-200\/5:hover{background-color:color-mix(in oklab, var(--color-neutral-200) 5%, transparent)}}.hover\:underline:hover{text-decoration-line:underline}}}@keyframes marquee{0%{transform:translate(0%)}to{transform:translate(-100%)}}@media (prefers-reduced-motion:reduce){.animate-marquee{animation-play-state:paused}}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false} \ No newline at end of file