Database time
This commit is contained in:
parent
27dd80830f
commit
1a1aee1195
1742
Cargo.lock
generated
1742
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -11,8 +11,8 @@ opt-level = 3
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
opt-level = "z"
|
opt-level = "s"
|
||||||
lto = true
|
lto = "fat"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|
||||||
|
@ -24,9 +24,11 @@ clap = { version = "4.5.4", features = ["derive", "env"] }
|
||||||
color-eyre = "0.6.3"
|
color-eyre = "0.6.3"
|
||||||
config = "0.14.0"
|
config = "0.14.0"
|
||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
minijinja = { version = "2.0.1", features = ["loader"] }
|
maud = "0.26.0"
|
||||||
notify = "6.1.1"
|
notify = "6.1.1"
|
||||||
pathdiff = "0.2.1"
|
pathdiff = "0.2.1"
|
||||||
|
redact = { version = "0.1.10", features = ["serde"] }
|
||||||
|
sea-orm = { version = "0.12.15", features = ["sqlx-postgres", "runtime-tokio-rustls", "debug-print"] }
|
||||||
serde = "1.0.201"
|
serde = "1.0.201"
|
||||||
thiserror = "1.0.60"
|
thiserror = "1.0.60"
|
||||||
tokio = { version = "1.37.0", features = ["full"] }
|
tokio = { version = "1.37.0", features = ["full"] }
|
||||||
|
|
61
src/cli.rs
61
src/cli.rs
|
@ -1,10 +1,9 @@
|
||||||
use crate::{observe, prelude::*, router::NewRouterError};
|
pub mod prelude;
|
||||||
use prelude::*;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
mod prelude {
|
mod run;
|
||||||
pub use clap::{Args, Parser, Subcommand};
|
|
||||||
}
|
use crate::{observe, prelude::*};
|
||||||
|
use prelude::*;
|
||||||
|
|
||||||
/// Web application for managing lyrics and live displays
|
/// Web application for managing lyrics and live displays
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
@ -21,61 +20,23 @@ pub struct Cli {
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
/// Run the web application server
|
/// Run the web application server
|
||||||
Run(Run),
|
Run(run::Run),
|
||||||
}
|
|
||||||
|
|
||||||
/// Doc comment
|
|
||||||
#[derive(Args)]
|
|
||||||
struct Run {
|
|
||||||
/// Whether or not to watch certain resource files for changes and reload accordingly
|
|
||||||
#[arg(short, long, default_value = None)]
|
|
||||||
pub watch: bool,
|
|
||||||
|
|
||||||
/// The address to bind to - you almost certainly want to use :: or 0.0.0.0 instead of the default
|
|
||||||
#[arg(short = 'H', long, default_value = "::1")]
|
|
||||||
pub host: String,
|
|
||||||
|
|
||||||
/// The port to bind to
|
|
||||||
#[arg(short, long, default_value = "3000")]
|
|
||||||
pub port: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
|
||||||
pub enum RunError {
|
|
||||||
#[error("router error: {0}")]
|
|
||||||
Router(#[from] NewRouterError),
|
|
||||||
|
|
||||||
#[error("io error: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Run {
|
|
||||||
pub async fn run(&self) -> Result<(), RunError> {
|
|
||||||
let (router, _watchers) = crate::router::router(self.watch).await?;
|
|
||||||
Ok(
|
|
||||||
crate::webserver::webserver(router, self.watch, Some(&self.host), Some(self.port))
|
|
||||||
.await?,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cli() -> Cli {
|
|
||||||
Cli::parse()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum ExecError {
|
pub enum ExecError {
|
||||||
#[error("run error: {0}")]
|
#[error("run error: {0}")]
|
||||||
Run(#[from] RunError),
|
Run(#[from] run::RunError),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Eyre(#[from] color_eyre::Report),
|
Eyre(#[from] color_eyre::Report),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cli {
|
impl Cli {
|
||||||
pub async fn exec(self) -> Result<(), ExecError> {
|
pub async fn exec() -> Result<(), ExecError> {
|
||||||
observe::setup_logging(&self.log_env_filter)?;
|
let cli = Cli::parse();
|
||||||
match self.command {
|
observe::setup_logging(&cli.log_env_filter)?;
|
||||||
|
match cli.command {
|
||||||
Commands::Run(args) => Ok(args.run().await?),
|
Commands::Run(args) => Ok(args.run().await?),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
3
src/cli/prelude.rs
Normal file
3
src/cli/prelude.rs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
#![allow(unused_imports)]
|
||||||
|
pub use clap::{Args, Parser, Subcommand};
|
||||||
|
pub use thiserror::Error;
|
52
src/cli/run.rs
Normal file
52
src/cli/run.rs
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
use super::prelude::*;
|
||||||
|
use crate::{
|
||||||
|
router::NewRouterError,
|
||||||
|
state::{NewStateError, State},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Doc comment
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct Run {
|
||||||
|
/// Whether or not to watch certain resource files for changes and reload accordingly
|
||||||
|
#[arg(short, long, default_value = None)]
|
||||||
|
pub watch: bool,
|
||||||
|
|
||||||
|
/// The address to bind to - you almost certainly want to use :: or 0.0.0.0 instead of the default
|
||||||
|
#[arg(short = 'H', long, default_value = "::1")]
|
||||||
|
pub host: String,
|
||||||
|
|
||||||
|
/// The port to bind to
|
||||||
|
#[arg(short, long, default_value = "3000")]
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
/// The database connection string to use
|
||||||
|
#[arg(
|
||||||
|
short,
|
||||||
|
long,
|
||||||
|
default_value = "postgresql://lyrs?host=/var/run/postgresql"
|
||||||
|
)]
|
||||||
|
pub database_connection_string: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum RunError {
|
||||||
|
#[error("router error: {0}")]
|
||||||
|
Router(#[from] NewRouterError),
|
||||||
|
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("new state error: {0}")]
|
||||||
|
State(#[from] NewStateError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Run {
|
||||||
|
pub async fn run(&self) -> Result<(), RunError> {
|
||||||
|
let app_state = State::try_new(&self.database_connection_string).await?;
|
||||||
|
let (router, _watchers) = crate::router::router(app_state, self.watch).await?;
|
||||||
|
Ok(
|
||||||
|
crate::webserver::webserver(router, self.watch, Some(&self.host), Some(self.port))
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,3 @@
|
||||||
use crate::prelude::*;
|
|
||||||
|
|
||||||
mod cli;
|
mod cli;
|
||||||
mod file_watcher;
|
mod file_watcher;
|
||||||
mod observe;
|
mod observe;
|
||||||
|
@ -8,10 +6,12 @@ mod router;
|
||||||
mod state;
|
mod state;
|
||||||
mod static_files;
|
mod static_files;
|
||||||
mod tailwind;
|
mod tailwind;
|
||||||
mod templates;
|
|
||||||
mod webserver;
|
mod webserver;
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
use cli::Cli;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> AnonResult<()> {
|
async fn main() -> AnonResult<()> {
|
||||||
Ok(cli::cli().exec().await?)
|
Ok(Cli::exec().await?)
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ pub fn setup_logging(env_filter: &str) -> Result<(), color_eyre::Report> {
|
||||||
))
|
))
|
||||||
.parse_lossy(env_filter);
|
.parse_lossy(env_filter);
|
||||||
|
|
||||||
info!("{filter}");
|
info!(%filter);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
239
src/router.rs
239
src/router.rs
|
@ -1,5 +1,3 @@
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
file_watcher::FileWatcher,
|
file_watcher::FileWatcher,
|
||||||
prelude::*,
|
prelude::*,
|
||||||
|
@ -7,34 +5,26 @@ use crate::{
|
||||||
static_files,
|
static_files,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
http::StatusCode,
|
||||||
extract::{MatchedPath, State},
|
|
||||||
http::{HeaderMap, Request, Response, StatusCode},
|
|
||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse},
|
||||||
routing::get,
|
routing::{get, post},
|
||||||
Router,
|
Form, Router,
|
||||||
};
|
};
|
||||||
use minijinja::context;
|
use maud::{html, Markup, PreEscaped, DOCTYPE};
|
||||||
use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer};
|
use redact::Secret;
|
||||||
use tower_livereload::LiveReloadLayer;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{info_span, Span};
|
use tower_http::trace::TraceLayer;
|
||||||
|
use tower_livereload::LiveReloadLayer;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum NewRouterError {
|
pub enum NewRouterError {
|
||||||
#[error("new state error: {0}")]
|
|
||||||
State(#[from] NewStateError),
|
|
||||||
|
|
||||||
#[error("watcher error: {0}")]
|
#[error("watcher error: {0}")]
|
||||||
Watcher(#[from] notify::Error),
|
Watcher(#[from] notify::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum ReqError {
|
pub enum ReqError {}
|
||||||
#[error("template error: {0}")]
|
|
||||||
Template(#[from] minijinja::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for ReqError {
|
impl IntoResponse for ReqError {
|
||||||
fn into_response(self) -> axum::http::Response<axum::body::Body> {
|
fn into_response(self) -> axum::http::Response<axum::body::Body> {
|
||||||
|
@ -51,10 +41,9 @@ impl IntoResponse for ReqError {
|
||||||
pub type ReqResult<T> = Result<T, ReqError>;
|
pub type ReqResult<T> = Result<T, ReqError>;
|
||||||
|
|
||||||
pub async fn router(
|
pub async fn router(
|
||||||
|
state: AppState,
|
||||||
with_watchers: bool,
|
with_watchers: bool,
|
||||||
) -> Result<(Router, Vec<Option<FileWatcher>>), NewRouterError> {
|
) -> Result<(Router, Vec<Option<FileWatcher>>), NewRouterError> {
|
||||||
let state = AppState::try_new().await?;
|
|
||||||
|
|
||||||
let live_reload_layer: Option<LiveReloadLayer> = if with_watchers {
|
let live_reload_layer: Option<LiveReloadLayer> = if with_watchers {
|
||||||
Some(LiveReloadLayer::new())
|
Some(LiveReloadLayer::new())
|
||||||
} else {
|
} else {
|
||||||
|
@ -69,86 +58,174 @@ pub async fn router(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let template_file_watcher = state.clone().templates.start_watcher(orl()).await?;
|
|
||||||
let (static_file_service, static_file_watcher) = static_files::router(orl())?;
|
let (static_file_service, static_file_watcher) = static_files::router(orl())?;
|
||||||
|
|
||||||
let mut result = Router::new()
|
let mut result = Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/about", get(about))
|
.route("/about", get(about))
|
||||||
|
.route("/login", get(login))
|
||||||
|
.route("/login", post(authenticate))
|
||||||
|
.route("/register", get(register))
|
||||||
|
.route("/register", post(create_user))
|
||||||
.nest_service("/static", static_file_service)
|
.nest_service("/static", static_file_service)
|
||||||
// `TraceLayer` is provided by tower-http so you have to add that as a dependency.
|
.layer(TraceLayer::new_for_http())
|
||||||
// It provides good defaults but is also very customizable.
|
|
||||||
//
|
|
||||||
// See https://docs.rs/tower-http/0.1.1/tower_http/trace/index.html for more details.
|
|
||||||
//
|
|
||||||
// If you want to customize the behavior using closures here is how.
|
|
||||||
.layer(
|
|
||||||
TraceLayer::new_for_http()
|
|
||||||
.make_span_with(|request: &Request<_>| {
|
|
||||||
// Log the matched route's path (with placeholders not filled in).
|
|
||||||
// Use request.uri() or OriginalUri if you want the real path.
|
|
||||||
let matched_path = request
|
|
||||||
.extensions()
|
|
||||||
.get::<MatchedPath>()
|
|
||||||
.map(MatchedPath::as_str);
|
|
||||||
|
|
||||||
info_span!(
|
|
||||||
"http_request",
|
|
||||||
method = ?request.method(),
|
|
||||||
matched_path,
|
|
||||||
some_other_field = tracing::field::Empty,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.on_request(|_request: &Request<_>, _span: &Span| {
|
|
||||||
// You can use `_span.record("some_other_field", value)` in one of these
|
|
||||||
// closures to attach a value to the initially empty field in the info_span
|
|
||||||
// created above.
|
|
||||||
})
|
|
||||||
.on_response(|response: &Response<_>, latency: Duration, _span: &Span| {
|
|
||||||
trace!("on_response: {response:?} in {latency:?}");
|
|
||||||
})
|
|
||||||
.on_body_chunk(|_chunk: &Bytes, _latency: Duration, _span: &Span| {
|
|
||||||
// ...
|
|
||||||
})
|
|
||||||
.on_eos(
|
|
||||||
|_trailers: Option<&HeaderMap>, _stream_duration: Duration, _span: &Span| {
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.on_failure(
|
|
||||||
|error: ServerErrorsFailureClass, latency: Duration, _span: &Span| {
|
|
||||||
error!("on_failure: {error:?} in {latency:?}");
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with_state(state.clone());
|
.with_state(state.clone());
|
||||||
|
|
||||||
if let Some(lr) = live_reload_layer {
|
if let Some(lr) = live_reload_layer {
|
||||||
result = result.clone().layer(lr);
|
result = result.clone().layer(lr);
|
||||||
}
|
}
|
||||||
|
|
||||||
let watchers = vec![template_file_watcher, static_file_watcher];
|
let watchers = vec![static_file_watcher];
|
||||||
|
|
||||||
Ok((result, watchers))
|
Ok((result, watchers))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(state))]
|
fn stylesheet(url: &str) -> Markup {
|
||||||
async fn index(State(state): State<AppState>) -> ReqResult<Html<String>> {
|
html! {
|
||||||
|
link rel="stylesheet" type="text/css" href=(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn head(page_title: &str) -> Markup {
|
||||||
|
html! {
|
||||||
|
(DOCTYPE)
|
||||||
|
meta charset="utf-8" {}
|
||||||
|
meta name="viewport" content="width=device-width, initial-scale=1" {}
|
||||||
|
title { (page_title) " - lyrs" }
|
||||||
|
(stylesheet("/static/style.css"));
|
||||||
|
|
||||||
|
script src="https://unpkg.com/htmx.org@1.9.12" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" crossorigin="anonymous" defer {}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn foot() -> Markup {
|
||||||
|
html! {
|
||||||
|
footer class="p-2 border-t-2 border-surface0 flex overflow-x-scroll" {
|
||||||
|
section {
|
||||||
|
(PreEscaped("© 2024 "))
|
||||||
|
a .underline.text-mauve href="https://lyte.dev" { "lytedev" }
|
||||||
|
}
|
||||||
|
section .ml-auto {("Made with ❤️")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page(title: &str, content: Markup) -> ReqResult<Html<String>> {
|
||||||
Ok(Html(
|
Ok(Html(
|
||||||
state
|
html! {
|
||||||
.templates
|
(head(title))
|
||||||
.render("pages/index.jinja.html", context!())
|
body .bg-bg.text-text.min-h-lvh.flex.flex-col.font-sans {
|
||||||
.await?,
|
header class="drop-shadow border-b-2 border-surface0 bg-blue-500 flex overflow-x-scroll" {
|
||||||
|
a class="flex p-2 text-3xl font-mono text-mauve opacity-80 hover:bg-mauve hover:text-bg" href="/" { "lyrs" }
|
||||||
|
nav class="flex flex-1 justify-start" {
|
||||||
|
a class="flex items-center p-2 hover:bg-mauve hover:text-bg" href="/login" { "Login" }
|
||||||
|
a class="flex items-center p-2 hover:bg-mauve hover:text-bg" href="/register" { "Register" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main class="flex flex-col flex-1 relative overflow-x-scroll bg-mantle" {
|
||||||
|
(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(foot())
|
||||||
|
}.into_string()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(state))]
|
async fn index() -> ReqResult<Html<String>> {
|
||||||
async fn about(State(state): State<AppState>) -> ReqResult<Html<String>> {
|
page("index", html! { "Index" })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn about() -> ReqResult<Html<String>> {
|
||||||
|
page("index", html! { "About" })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn center_hero_form(title: &str, content: Markup, subform: Markup) -> Markup {
|
||||||
|
html! {
|
||||||
|
section class="hero grow" {
|
||||||
|
form class="flex flex-col gap-2 w-full max-w-sm" method="post" {
|
||||||
|
header {
|
||||||
|
h1 class="pb-2 text-center text-xl" { (title) }
|
||||||
|
}
|
||||||
|
(content)
|
||||||
|
input class="bg-mauve" value="Submit" type="submit";
|
||||||
|
}
|
||||||
|
(subform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn labelled_input(label: &str, input: Markup) -> Markup {
|
||||||
|
html! {
|
||||||
|
label class="flex flex-col" {
|
||||||
|
(label)
|
||||||
|
(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login() -> ReqResult<Html<String>> {
|
||||||
|
let form = html! {
|
||||||
|
(labelled_input("Username", html!{
|
||||||
|
input class="input" type="text" name="username" autocomplete="username" required;
|
||||||
|
}))
|
||||||
|
(labelled_input("Password", html!{
|
||||||
|
input class="input" type="password" name="password" autocomplete="current-password" required;
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
let subaction = html! {
|
||||||
|
small class="mt-4" {
|
||||||
|
"Need an account? "
|
||||||
|
a href="/register" {"Get one"}
|
||||||
|
"."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
page("login", center_hero_form("Login", form, subaction))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register() -> ReqResult<Html<String>> {
|
||||||
|
let form = html! {
|
||||||
|
(labelled_input("Username", html!{
|
||||||
|
input class="input" type="text" name="username" required;
|
||||||
|
}))
|
||||||
|
(labelled_input("Password", html!{
|
||||||
|
input class="input" type="password" name="password" autocomplete="new-password" required;
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
let subaction = html! {
|
||||||
|
small class="mt-4" {
|
||||||
|
"Already have an account? "
|
||||||
|
a href="/login" {"Login"}
|
||||||
|
"."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
page("login", center_hero_form("Register", form, subaction))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct Creds {
|
||||||
|
username: String,
|
||||||
|
password: Secret<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument]
|
||||||
|
async fn authenticate(Form(creds): Form<Creds>) -> ReqResult<Html<String>> {
|
||||||
|
info!("login attempt");
|
||||||
Ok(Html(
|
Ok(Html(
|
||||||
state
|
html! {
|
||||||
.templates
|
"no"
|
||||||
.render("pages/about.jinja.html", context!())
|
}
|
||||||
.await?,
|
.into_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument]
|
||||||
|
async fn create_user(Form(creds): Form<Creds>) -> ReqResult<Html<String>> {
|
||||||
|
info!("registration attempt");
|
||||||
|
Ok(Html(
|
||||||
|
html! {
|
||||||
|
"no"
|
||||||
|
}
|
||||||
|
.into_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
24
src/state.rs
24
src/state.rs
|
@ -1,26 +1,22 @@
|
||||||
use crate::{
|
use crate::prelude::*;
|
||||||
prelude::*,
|
use sea_orm::{Database, DatabaseConnection};
|
||||||
templates::{TemplateLoadError, Templates},
|
use thiserror::Error;
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct State {
|
pub struct State {
|
||||||
pub templates: Arc<Templates>,
|
db: DatabaseConnection,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
pub async fn try_new() -> Result<Self, NewStateError> {
|
pub async fn try_new(database_connection_string: &str) -> Result<Self, NewStateError> {
|
||||||
let templates = Arc::new(Templates::try_load("src/templates").await?);
|
Ok(Self {
|
||||||
|
db: Database::connect(database_connection_string).await?,
|
||||||
Ok(Self { templates })
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum NewStateError {
|
pub enum NewStateError {
|
||||||
#[error("template load error: {0}")]
|
#[error("database error: {0}")]
|
||||||
TemplateLoad(#[from] TemplateLoadError),
|
Database(#[from] sea_orm::DbErr),
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,64 @@
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
|
||||||
|
main a[href] {
|
||||||
|
@apply text-mauve underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
@apply opacity-80 hover:opacity-100 p-2 border-2 bg-bg border-surface2 rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input[type=submit] {
|
||||||
|
@apply opacity-80 hover:opacity-100 p-2 border-0 rounded cursor-pointer text-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
@apply flex flex-col p-2 justify-center items-center relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply flex flex-col drop-shadow-xl border-2 border-surface2 rounded p-2 gap-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: iosevkalyte;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local("Iosevka"), url("/static/font/iosevkalyteweb-regular.subset.woff2");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: iosevkalyte;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local("Iosevka"), url("/static/font/iosevkalyteweb-italic.subset.woff2");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: iosevkalyte;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local("Iosevka"), url("/static/font/iosevkalyteweb-bold.subset.woff2");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: iosevkalyte;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
src: local("Iosevka"), url("/static/font/iosevkalyteweb-bolditalic.subset.woff2");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Catppuccin Mocha */
|
/* Catppuccin Mocha */
|
||||||
--Rosewater: #f5e0dc;
|
--Rosewater: #f5e0dc;
|
||||||
|
@ -63,3 +120,9 @@
|
||||||
--Crust: #dce0e8;
|
--Crust: #dce0e8;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:autofill,
|
||||||
|
:-webkit-autofill {
|
||||||
|
filter: none !important;
|
||||||
|
background-color: #f00 !important;
|
||||||
|
}
|
115
src/templates.rs
115
src/templates.rs
|
@ -1,115 +0,0 @@
|
||||||
use crate::{file_watcher::prelude::*, prelude::*};
|
|
||||||
use minijinja::Environment;
|
|
||||||
use pathdiff::diff_paths;
|
|
||||||
use std::{
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
sync::Arc,
|
|
||||||
};
|
|
||||||
use thiserror::Error;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tower_livereload::Reloader;
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
|
||||||
pub enum TemplateLoadError {
|
|
||||||
#[error("template error: {0}")]
|
|
||||||
Minijinja(#[from] minijinja::Error),
|
|
||||||
|
|
||||||
#[error("io error: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct Templates {
|
|
||||||
env: Arc<Mutex<Environment<'static>>>,
|
|
||||||
dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Templates {
|
|
||||||
pub fn for_dir<P: Into<PathBuf>>(dir: P) -> Self {
|
|
||||||
let env = Arc::new(Mutex::new(Environment::new()));
|
|
||||||
Self {
|
|
||||||
env,
|
|
||||||
dir: dir.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn try_load<P: Into<PathBuf>>(dir: P) -> Result<Self, TemplateLoadError> {
|
|
||||||
let result = Self::for_dir(dir);
|
|
||||||
result.load_env().await?;
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start_watcher(
|
|
||||||
self: Arc<Self>,
|
|
||||||
reloader: Option<Reloader>,
|
|
||||||
) -> Result<Option<FileWatcher>, notify::Error> {
|
|
||||||
if let Some(rl) = reloader {
|
|
||||||
Ok(Some(self.watch(rl).await?))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn watch(self: Arc<Self>, reloader: Reloader) -> Result<FileWatcher, notify::Error> {
|
|
||||||
let watcher = file_watcher(self.dir.clone(), move |ev| {
|
|
||||||
futures::executor::block_on(async {
|
|
||||||
if ev.kind.is_create() || ev.kind.is_modify() {
|
|
||||||
for p in ev.paths {
|
|
||||||
let _ = self.load_template(&p).await;
|
|
||||||
}
|
|
||||||
reloader.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})?;
|
|
||||||
Ok(watcher)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self, p))]
|
|
||||||
pub async fn load_template<P>(&self, p: P) -> Result<(), TemplateLoadError>
|
|
||||||
where
|
|
||||||
P: AsRef<Path> + Copy,
|
|
||||||
{
|
|
||||||
let p = p.as_ref();
|
|
||||||
if p.is_dir() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let filename: String = diff_paths(p, "src/templates")
|
|
||||||
.unwrap()
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned();
|
|
||||||
if [".bck", ".tmp"].iter().any(|s| filename.ends_with(s)) {
|
|
||||||
debug!("skipping temporary file");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
info!(filename);
|
|
||||||
Ok(self
|
|
||||||
.env
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.add_template_owned(filename, std::fs::read_to_string(p)?)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn load_env(&self) -> Result<(), TemplateLoadError> {
|
|
||||||
for d in walkdir::WalkDir::new(&self.dir) {
|
|
||||||
match d {
|
|
||||||
Ok(d) => self.load_template(d.path()).await?,
|
|
||||||
Err(_) => todo!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn render<S: serde::ser::Serialize>(
|
|
||||||
&self,
|
|
||||||
template_name: &str,
|
|
||||||
context: S,
|
|
||||||
) -> Result<String, minijinja::Error> {
|
|
||||||
Ok(self
|
|
||||||
.env
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.get_template(template_name)?
|
|
||||||
.render(context)?)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
|
|
||||||
<head hx-preserve="true">
|
|
||||||
<title>Sup</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="/static/style.css" />
|
|
||||||
<script hx-preserve="true" src="https://unpkg.com/htmx.org@1.9.12"
|
|
||||||
integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2"
|
|
||||||
crossorigin="anonymous" defer></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="bg-base text-text">
|
|
||||||
Page Template
|
|
||||||
<nav>
|
|
||||||
<a href="/">Index</a>
|
|
||||||
<a href="/about">About</a>
|
|
||||||
</nav>
|
|
||||||
{% block body %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
|
@ -1,7 +0,0 @@
|
||||||
{% extends "page.jinja.html" %}
|
|
||||||
{% block body %}
|
|
||||||
<h1>About</h1>
|
|
||||||
<p class="important">
|
|
||||||
Welcome to my awesome about page!
|
|
||||||
</p>
|
|
||||||
{% endblock %}
|
|
|
@ -1,7 +0,0 @@
|
||||||
{% extends "page.jinja.html" %}
|
|
||||||
{% block body %}
|
|
||||||
<h1>Index</h1>
|
|
||||||
<p class="important">
|
|
||||||
Welcome to my awesome index!
|
|
||||||
</p>
|
|
||||||
{% endblock %}
|
|
BIN
static/font/iosevkalyteweb-bold.subset.woff2
Normal file
BIN
static/font/iosevkalyteweb-bold.subset.woff2
Normal file
Binary file not shown.
BIN
static/font/iosevkalyteweb-bolditalic.subset.woff2
Normal file
BIN
static/font/iosevkalyteweb-bolditalic.subset.woff2
Normal file
Binary file not shown.
BIN
static/font/iosevkalyteweb-italic.subset.woff2
Normal file
BIN
static/font/iosevkalyteweb-italic.subset.woff2
Normal file
Binary file not shown.
BIN
static/font/iosevkalyteweb-regular.subset.woff2
Normal file
BIN
static/font/iosevkalyteweb-regular.subset.woff2
Normal file
Binary file not shown.
|
@ -1,6 +1,11 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ["./src/**/*"], theme: {
|
content: ["./src/**/*"], theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['ui-sans-serif', 'system-ui', 'sans-serif', "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"],
|
||||||
|
mono: ['iosevkalyte', 'ui-monospace', 'monospace'],
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
rosewater: "var(--Rosewater)",
|
rosewater: "var(--Rosewater)",
|
||||||
flamingo: "var(--Flamingo)",
|
flamingo: "var(--Flamingo)",
|
||||||
|
@ -25,11 +30,10 @@ module.exports = {
|
||||||
surface2: "var(--Surface2)",
|
surface2: "var(--Surface2)",
|
||||||
surface1: "var(--Surface1)",
|
surface1: "var(--Surface1)",
|
||||||
surface0: "var(--Surface0)",
|
surface0: "var(--Surface0)",
|
||||||
base: "var(--Base)",
|
bg: "var(--Base)",
|
||||||
mantle: "var(--Mantle)",
|
mantle: "var(--Mantle)",
|
||||||
crust: "var(--Crust)",
|
crust: "var(--Crust)",
|
||||||
},
|
},
|
||||||
extend: {
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|
Loading…
Reference in a new issue