feat(webserver): Vendor and reimplement main pages in leptos

This commit is contained in:
Tristan Daniël Maat 2025-11-24 03:29:18 +08:00
parent aeba7301b0
commit 6faf6a0e5d
Signed by: tlater
GPG key ID: 02E935006CF2E8E7
22 changed files with 4664 additions and 166 deletions

View file

@ -0,0 +1,60 @@
use leptos::prelude::*;
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_router::{
StaticSegment,
components::{Route, Router, Routes},
};
mod homepage;
mod mail;
use crate::components::Navbar;
use homepage::HomePage;
use mail::Mail;
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="description" content="tlater.net homepage" />
<meta name="author" content="Tristan Daniël Maat" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<AutoReload options=options.clone() />
<HydrationScripts options />
<MetaTags />
</head>
<body>
<App />
</body>
</html>
}
}
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
view! {
// injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg/tlaternet-webserver.css" />
// sets the document title
<Title text="Welcome to Leptos" />
<Navbar />
// content for this welcome page
<Router>
<main>
<Routes fallback=|| "Page not found.".into_view()>
<Route path=StaticSegment("") view=HomePage />
<Route path=StaticSegment("mail") view=Mail />
</Routes>
</main>
</Router>
}
}

View file

@ -0,0 +1,81 @@
use leptos::prelude::*;
use markdown_view_leptos::markdown_view;
#[component]
pub fn HomePage() -> impl IntoView {
view! {
<section class="section">
<div class="container">
<h1 class="title has-text-weight-normal is-family-monospace">
"$ "<span id="typed-welcome" />
</h1>
<hr />
<div class="columns">
<div class="column content">
{markdown_view!(
r#"
### About Me
Looks like you found my website. I suppose introductions are
in order.
My name's Tristan, I'm an avid Dutch-South African software
engineer. You probably either met me at an open source conference,
a hackathon, a badminton session or at a roleplaying table.
If not, well, this is also a great place to "meet" me. Have a
nosey!
### This Website
There is not a whole lot here at the moment.
You may find the following interesting though:
- A [little web app](~/src/music_sample.html) showing
off what WebGL can do in combination with the JavaScript
Audio interface.
"#
)}
</div>
<div class="column content">
{markdown_view!(
r#"### My Work
I'm interested in a variety of things in the open source
world. Perhaps thanks to my pursuit of the perfect Linux desktop,
this has revolved a lot around reproducible build and deployment
systems for the last few years, initially starting with
[BuildStream](https://buildstream.build/) back in ~2017. I gave a
couple of talks on it at build meetups in the UK in subsequent
years, though sadly most evidence of that appears to have
disappeared.
Since then this has culminated in a strong fondness for
[NixOS](https://nixos.org/) and Nix, as its active community makes
private use cases much more feasible. As such, I have a vested
interest in making this community as large as possible - I post a
lot on the NixOS [discourse](https://discourse.nixos.org/) trying
to help newcomers out where I can.
I also just enjoy Programming, my core languages for personal work
are currently probably Rust and Python, although I have a very
varied background. This is in part due to my former work as a
consultant, which required new languages every few months. I have
experience from JavaScript over Elm to Kotlin, but eventually I
hope I might only need to write Rust ;)
If you're interested in seeing these things for yourself,
visit my [Gitlab](https://gitlab.com/tlater) and
[GitHub](https://github.com/tlater) pages.
"#
)}
</div>
</div>
</div>
</section>
}
}

View file

@ -0,0 +1,206 @@
use leptos::logging;
use leptos::{ev::SubmitEvent, prelude::*, server_fn::codec::JsonEncoding};
use markdown_view_leptos::markdown_view;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use web_sys::HtmlFormElement;
#[server]
async fn submit_mail(mail: String, subject: String, message: String) -> Result<String, MailError> {
use crate::AppState;
const NTFY_TOPIC_CREDENTIAL_NAME: &str = "ntfy-topic";
let mail = format!("From: {}\nSubject: {}\n{}", mail, subject, message);
logging::log!("{}", mail);
let state = use_context::<AppState>().unwrap();
let ntfy_topic = std::fs::read_to_string(
state
.config
.credentials_directory
.join(NTFY_TOPIC_CREDENTIAL_NAME),
)?;
let ntfy_url = state.config.ntfy_instance.join(ntfy_topic.trim())?;
state
.http_client
.post(ntfy_url)
.body(mail)
.send()
.await?
.error_for_status()?;
leptos_axum::redirect("/mail");
Ok("Mail successfully sent!".to_owned())
}
#[component]
fn MailForm() -> impl IntoView {
use web_sys::wasm_bindgen::JsCast as _;
let submit_mail = ServerAction::<SubmitMail>::new();
let flash_message = submit_mail.value();
view! {
<Show when=move || { flash_message.get().is_some() }>
<div
class="notification is-light"
class=("is-success", move || flash_message.get().is_some_and(|m| m.is_ok()))
class=("is-danger", move || flash_message.get().is_some_and(|m| m.is_err()))
>
<button
class="delete"
aria-label="Close"
on:click=move |_| {
*flash_message.write() = None;
}
/>
<span role="alert">
{move || match flash_message.get() {
None => "".to_owned(),
Some(Ok(message)) => message,
Some(Err(error)) => format!("{}", error),
}}
</span>
</div>
</Show>
<ActionForm
action=submit_mail
on:submit=move |ev: SubmitEvent| {
let form = ev.target().map(|target| target.dyn_into::<HtmlFormElement>());
if let Some(target) = form {
target.unwrap().reset();
} else {
logging::log!("Failed to reset form");
}
}
>
<div class="field">
<label class="label" for="mail">
Email address
</label>
<div class="control">
<input
id="mail"
class="input"
type="email"
placeholder="Your address"
name="mail"
required
/>
</div>
</div>
<div class="field">
<label class="label" for="subject">
Subject
</label>
<div class="control">
<input
id="subject"
class="input"
type="text"
placeholder="E.g. There's a typo on your home page!"
name="subject"
autocomplete="off"
required
/>
</div>
</div>
<div class="field">
<label class="label" for="message">
Message
</label>
<textarea
id="message"
class="textarea"
type="text"
rows="6"
name="message"
autocomplete="off"
required
/>
</div>
<div class="control">
<div class="field">
<button type="submit" class="button is-link">
Send
</button>
</div>
</div>
</ActionForm>
}
}
#[component]
pub fn Mail() -> impl IntoView {
view! {
<section class="section">
<div class="container">
<h1 class="title has-text-weight-normal">Contact Me</h1>
<div class="columns">
<div class="column">
<MailForm />
</div>
<div class="column content">
{markdown_view!(
r#"
Any messages you enter here are directly forwarded to me. I aim to
respond within a day.
Don't be upset about the form, I want to avoid the spam
publishing your email address brings with it... And minimize
the amount of mail that doesn't reach me, this form is an
exception in all my spam filters, you see ;)
"#
)}
</div>
</div>
</div>
</section>
}
}
#[derive(Error, Debug, Clone, Serialize, Deserialize)]
pub enum MailError {
#[error("This form appears to currently be broken :(")]
Permanent,
#[error("Mail service appears to be down; please try again later")]
Temporary,
#[error("Server error: {0}")]
ServerFnError(ServerFnErrorErr),
}
impl From<url::ParseError> for MailError {
fn from(error: url::ParseError) -> Self {
logging::error!("Invalid ntfy URL: {error}");
Self::Permanent
}
}
impl From<std::io::Error> for MailError {
fn from(error: std::io::Error) -> Self {
logging::error!("Couldn't read ntfy topic secret: {error}");
Self::Permanent
}
}
impl From<reqwest::Error> for MailError {
fn from(error: reqwest::Error) -> Self {
logging::error!("Failed to connect to ntfy: {error}");
Self::Temporary
}
}
impl FromServerFnError for MailError {
type Encoder = JsonEncoding;
fn from_server_fn_error(value: ServerFnErrorErr) -> Self {
Self::ServerFnError(value)
}
}

View file

@ -0,0 +1,45 @@
use leptos::prelude::*;
#[component]
pub fn Navbar() -> impl IntoView {
let (active, set_active) = signal(false);
view! {
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item has-text-primary is-uppercase" href="/">
tlater
</a>
<a
role="button"
on:click=move |_| { set_active.update(|active: &mut bool| *active = !*active) }
class="navbar-burger"
class=("is-active", move || active.get())
aria-label="menu"
aria-controls="main-navigation"
aria-expanded=move || if active.get() { "true" } else { "false" }
>
<span aria-hidden="true" />
<span aria-hidden="true" />
<span aria-hidden="true" />
<span aria-hidden="true" />
</a>
</div>
<div id="main-navigation" class="navbar-menu" class=("is-active", move || active.get())>
<div class="navbar-start">
<a class="navbar-item" href="/mail">
"E-Mail"
</a>
<a class="navbar-item" href="https://www.gitlab.com/tlater">
GitLab
</a>
<a class="navbar-item" href="https://www.github.com/TLATER">
GitHub
</a>
</div>
</div>
</nav>
}
}

View file

@ -0,0 +1,73 @@
pub mod app;
mod components;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use crate::app::*;
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(App);
}
#[cfg(feature = "ssr")]
pub use appstate::{AppState, Config};
#[cfg(feature = "ssr")]
mod appstate {
use axum::extract::FromRef;
use figment::{Figment, providers::Format};
use leptos::config::LeptosOptions;
use reqwest::Client;
use serde::Deserialize;
use std::path::PathBuf;
use url::Url;
#[derive(Deserialize, Debug, Clone)]
pub struct Config {
pub credentials_directory: PathBuf,
pub ntfy_instance: Url,
}
impl Config {
pub fn parse() -> Self {
let config_path = std::env::var_os("TLATERNET_CONFIG")
.map(PathBuf::from)
.or_else(|| {
std::env::current_dir()
.map(|dir| dir.join("config.toml"))
.ok()
});
let config: Result<Config, figment::Error> = if let Some(config_path) = config_path {
Figment::new().merge(figment::providers::Toml::file_exact(config_path))
} else {
Figment::new()
}
.merge(figment::providers::Env::raw().only(&["CREDENTIALS_DIRECTORY"]))
.merge(figment::providers::Env::prefixed("TLATERNET_"))
.extract();
match config {
Ok(config) => {
if !config.credentials_directory.join("ntfy-topic").exists() {
leptos::logging::error!("Failed to find ntfy-topic credential");
std::process::exit(1);
}
config
}
Err(error) => {
leptos::logging::error!("Failed to parse configuration: {:?}", error);
std::process::exit(1);
}
}
}
}
#[derive(FromRef, Debug, Clone)]
pub struct AppState {
pub config: Config,
pub http_client: Client,
pub leptos_options: LeptosOptions,
}
}

View file

@ -0,0 +1,54 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::Router;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_axum::{LeptosRoutes, generate_route_list};
use tlaternet_webserver::app::*;
use tlaternet_webserver::{AppState, Config};
let config = Config::parse();
let (addr, leptos_options) = {
let conf = get_configuration(None).unwrap();
let addr = conf.leptos_options.site_addr;
let leptos_options = conf.leptos_options;
(addr, leptos_options)
};
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
let http_client = reqwest::Client::new();
let state = AppState {
config,
http_client,
leptos_options,
};
let app = Router::new()
.leptos_routes(&state, routes, {
let leptos_options = state.leptos_options.clone();
move || shell(leptos_options.clone())
})
.fallback::<_, (_, _, axum::extract::State<AppState>, _)>(
leptos_axum::file_and_error_handler(shell),
)
.with_state(state);
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
log!("listening on http://{}", &addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}
#[cfg(not(feature = "ssr"))]
pub fn main() {
// no client-side main function
// unless we want this to work with e.g., Trunk for pure client-side testing
// see lib.rs for hydration function instead
}