feat: bootstrap htmx templating system
parent
ad015b47e4
commit
b3e49be299
|
@ -1,4 +1,4 @@
|
|||
use std::time::Duration;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use crate::server;
|
||||
|
||||
|
@ -9,12 +9,17 @@ pub fn serve(config: &crate::config::Config) -> u8 {
|
|||
|
||||
tracing::info!("Initializing database and running migrations");
|
||||
|
||||
// TODO remove unwraps
|
||||
let store =
|
||||
gpodder_sqlite::SqliteRepository::from_path(config.data_dir.join(crate::DB_FILENAME))
|
||||
.unwrap();
|
||||
let tera = crate::web::initialize_tera().unwrap();
|
||||
let store = gpodder::GpodderRepository::new(store);
|
||||
|
||||
let ctx = server::Context { store };
|
||||
let ctx = server::Context {
|
||||
store,
|
||||
tera: Arc::new(tera),
|
||||
};
|
||||
let app = server::app(ctx.clone());
|
||||
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
mod cli;
|
||||
mod config;
|
||||
mod server;
|
||||
mod web;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ pub type AppResult<T> = Result<T, AppError>;
|
|||
pub enum AppError {
|
||||
// Db(db::DbError),
|
||||
IO(std::io::Error),
|
||||
Tera(tera::Error),
|
||||
Other(Box<dyn std::error::Error + 'static + Send + Sync>),
|
||||
BadRequest,
|
||||
Unauthorized,
|
||||
|
@ -21,6 +22,7 @@ impl fmt::Display for AppError {
|
|||
match self {
|
||||
// Self::Db(_) => write!(f, "database error"),
|
||||
Self::IO(_) => write!(f, "io error"),
|
||||
Self::Tera(_) => write!(f, "tera error"),
|
||||
Self::Other(_) => write!(f, "other error"),
|
||||
Self::BadRequest => write!(f, "bad request"),
|
||||
Self::Unauthorized => write!(f, "unauthorized"),
|
||||
|
@ -33,6 +35,7 @@ impl std::error::Error for AppError {
|
|||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
// Self::Db(err) => Some(err),
|
||||
Self::Tera(err) => Some(err),
|
||||
Self::IO(err) => Some(err),
|
||||
Self::Other(err) => Some(err.as_ref()),
|
||||
Self::NotFound | Self::Unauthorized | Self::BadRequest => None,
|
||||
|
@ -46,6 +49,12 @@ impl From<std::io::Error> for AppError {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<tera::Error> for AppError {
|
||||
fn from(value: tera::Error) -> Self {
|
||||
Self::Tera(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self {
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
mod error;
|
||||
mod gpodder;
|
||||
mod r#static;
|
||||
mod web;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
|
@ -16,12 +19,14 @@ use tower_http::trace::TraceLayer;
|
|||
#[derive(Clone)]
|
||||
pub struct Context {
|
||||
pub store: ::gpodder::GpodderRepository,
|
||||
pub tera: Arc<tera::Tera>,
|
||||
}
|
||||
|
||||
pub fn app(ctx: Context) -> Router {
|
||||
Router::new()
|
||||
.merge(gpodder::router(ctx.clone()))
|
||||
.nest("/static", r#static::router())
|
||||
.nest("/_", web::router(ctx.clone()))
|
||||
.layer(axum::middleware::from_fn(header_logger))
|
||||
.layer(axum::middleware::from_fn(body_logger))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
use axum::{extract::State, http::HeaderMap, routing::get, Router};
|
||||
|
||||
use crate::web::{Page, TemplateExt, TemplateResponse, View};
|
||||
|
||||
use super::Context;
|
||||
|
||||
pub fn router(_ctx: Context) -> Router<Context> {
|
||||
Router::new().route("/", get(get_index))
|
||||
}
|
||||
|
||||
async fn get_index(State(ctx): State<Context>, headers: HeaderMap) -> TemplateResponse<Page<View>> {
|
||||
View::Index.page(&headers).response(&ctx.tera)
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
mod page;
|
||||
mod view;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{HeaderMap, Response, StatusCode},
|
||||
response::{Html, IntoResponse},
|
||||
};
|
||||
|
||||
pub use page::Page;
|
||||
pub use view::View;
|
||||
|
||||
const BASE_TEMPLATE: &str = "base.html";
|
||||
|
||||
/// Trait defining shared methods for working with typed Tera templates
|
||||
pub trait Template {
|
||||
/// Returns the name or path used to identify the template in the Tera struct
|
||||
fn template(&self) -> &'static str;
|
||||
|
||||
/// Render the template using the given Tera instance.
|
||||
///
|
||||
/// Templates are expected to manage their own context requirements if needed.
|
||||
fn render(&self, tera: &tera::Tera) -> tera::Result<String>;
|
||||
}
|
||||
|
||||
/// Useful additional functions on sized Template implementors
|
||||
pub trait TemplateExt: Sized + Template {
|
||||
fn response(self, tera: &Arc<tera::Tera>) -> TemplateResponse<Self> {
|
||||
TemplateResponse::new(tera, self)
|
||||
}
|
||||
|
||||
fn page(self, headers: &HeaderMap) -> Page<Self> {
|
||||
Page::new(self).headers(headers)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Sized + Template> TemplateExt for T {}
|
||||
|
||||
/// A specific instance of a template. This type can be used as a return type from Axum handlers.
|
||||
pub struct TemplateResponse<T> {
|
||||
tera: Arc<tera::Tera>,
|
||||
template: T,
|
||||
}
|
||||
|
||||
impl<T> TemplateResponse<T> {
|
||||
pub fn new(tera: &Arc<tera::Tera>, template: T) -> Self {
|
||||
Self {
|
||||
tera: Arc::clone(&tera),
|
||||
template,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Template> IntoResponse for TemplateResponse<T> {
|
||||
fn into_response(self) -> Response<Body> {
|
||||
match self.template.render(&self.tera) {
|
||||
Ok(s) => Html(s).into_response(),
|
||||
Err(err) => {
|
||||
tracing::error!("tera template failed: {err}");
|
||||
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn initialize_tera() -> tera::Result<tera::Tera> {
|
||||
let mut tera = tera::Tera::default();
|
||||
|
||||
tera.add_raw_templates([
|
||||
(BASE_TEMPLATE, include_str!("templates/base.html")),
|
||||
(View::Index.template(), include_str!("templates/index.html")),
|
||||
])?;
|
||||
|
||||
Ok(tera)
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
use super::Template;
|
||||
|
||||
const HX_REQUEST_HEADER: &str = "HX-Request";
|
||||
const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request";
|
||||
|
||||
/// Overarching template type that conditionally wraps its inner template with the base template if
|
||||
/// required, as derived from the request headers
|
||||
pub struct Page<T> {
|
||||
template: T,
|
||||
wrap_with_base: bool,
|
||||
}
|
||||
|
||||
impl<T: Template> Template for Page<T> {
|
||||
fn template(&self) -> &'static str {
|
||||
self.template.template()
|
||||
}
|
||||
|
||||
fn render(&self, tera: &tera::Tera) -> tera::Result<String> {
|
||||
let inner = self.template.render(tera)?;
|
||||
|
||||
if self.wrap_with_base {
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("inner", &inner);
|
||||
|
||||
tera.render(super::BASE_TEMPLATE, &ctx)
|
||||
} else {
|
||||
Ok(inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Page<T> {
|
||||
pub fn new(template: T) -> Self {
|
||||
Self {
|
||||
template,
|
||||
wrap_with_base: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn headers(mut self, headers: &HeaderMap) -> Self {
|
||||
let is_htmx_req = headers.get(HX_REQUEST_HEADER).is_some();
|
||||
let is_hist_restore_req = headers
|
||||
.get(HX_HISTORY_RESTORE_HEADER)
|
||||
.map(|val| val == HeaderValue::from_static("true"))
|
||||
.unwrap_or(false);
|
||||
|
||||
self.wrap_with_base = !is_htmx_req || is_hist_restore_req;
|
||||
|
||||
self
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script src="/static/htmx_2.0.4.min.js" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"></script>
|
||||
<link rel="stylesheet" href="/static/pico_2.1.1.classless.jade.min.css" />
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<style type="text/css">
|
||||
a:hover {
|
||||
cursor:pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav>
|
||||
</nav>
|
||||
<article id="content">
|
||||
{{ inner | safe }}
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,3 @@
|
|||
<h1>Otter</h1>
|
||||
|
||||
Otter is a self-hostable Gpodder implementation.
|
|
@ -0,0 +1,17 @@
|
|||
use super::Template;
|
||||
|
||||
pub enum View {
|
||||
Index,
|
||||
}
|
||||
|
||||
impl Template for View {
|
||||
fn template(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Index => "index.html",
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, tera: &tera::Tera) -> tera::Result<String> {
|
||||
tera.render(self.template(), &tera::Context::new())
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue