diff --git a/Cargo.toml b/Cargo.toml index e49d523..e936945 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,19 +20,19 @@ async-trait = "0.1.73" html-escape = "0.2.13" clap = { version = "4.4.6", features = ["derive"] } tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] } -axum = "0.6.20" +axum = "0.7.3" # db providers sqlx = { version = "0.7.3", features = ["runtime-tokio", "tls-rustls", "any"] } # notification providers teloxide = { version = "0.12.2", features = ["macros"], optional = true } mail-send = { version = "0.4.6", optional = true } tokio-rustls = { version = "0.25.0", optional = true } +# frontend +sailfish = { version = "0.8.3", optional = true } +axum-extra = "0.9.1" [features] -default = [ - "mysql", "sqlite", "postgres", - "telegram", "email" -] +default = ["mysql", "sqlite", "postgres", "telegram", "email", "web"] # all features by default # db drivers mysql = ["sqlx/mysql"] sqlite = ["sqlx/sqlite"] @@ -40,3 +40,5 @@ postgres = ["sqlx/postgres"] # notifier providers telegram = ["dep:teloxide"] email = ["dep:mail-send", "dep:tokio-rustls"] +# frontend +web = ["dep:sailfish"] diff --git a/sailfish.toml b/sailfish.toml new file mode 100644 index 0000000..c3efdc1 --- /dev/null +++ b/sailfish.toml @@ -0,0 +1 @@ +template_dirs = ["web"] diff --git a/src/config.rs b/src/config.rs index 86e94c7..1ecd3ee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,6 +5,8 @@ pub struct Config { pub overrides: ConfigOverrides, pub notifiers: ConfigNotifiers, + + pub template: ConfigTemplate, } #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] @@ -17,6 +19,55 @@ pub struct ConfigOverrides { pub date: bool, } + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ConfigTemplate { + pub title: String, + pub logo: String, + pub description: String, + pub placeholder_body: String, + pub canonical: Option, + pub palette: ConfigPalette, +} + +impl Default for ConfigTemplate { + fn default() -> Self { + ConfigTemplate { + title: "guestbook.rs".into(), + logo: "https://cdn.alemi.dev/social/someriver.jpg".into(), + description: "you found my guestbook! please take a moment to sign it (:".into(), + placeholder_body: "Kilroy was here Ω".into(), + canonical: None, + palette: ConfigPalette::default(), + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ConfigPalette { + pub bg_main: String, + pub bg_off: String, + pub fg_main: String, + pub fg_off: String, + pub accent: String, + pub accent_dark: String, + pub accent_light: String, +} + +impl Default for ConfigPalette { + fn default() -> Self { + ConfigPalette { + bg_main: "#201F29".into(), + bg_off: "#292835".into(), + fg_main: "#E8E1D3".into(), + fg_off: "#ADA9A1".into(), + accent: "#BF616A".into(), + accent_dark: "#824E53".into(), + accent_light: "#D1888E".into(), + } + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ConfigNotifiers { pub providers: Vec, diff --git a/src/main.rs b/src/main.rs index ab18d88..1bd1d2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -use std::{net::SocketAddr, io::Write}; use clap::{Parser, Subcommand}; use config::ConfigOverrides; @@ -11,6 +10,9 @@ mod model; mod storage; mod config; +#[cfg(feature = "web")] +mod web; + #[derive(Debug, Clone, Parser)] #[command(author, version, about)] @@ -62,15 +64,11 @@ async fn main() { .init(); match args.action { - CliAction::Default => { - let mut cfg = Config::default(); - cfg.notifiers.providers.push(NotifierProvider::Console); - #[cfg(feature = "telegram")] - cfg.notifiers.providers.push(NotifierProvider::Telegram { token: "asd".into(), chat_id: -1 }); - println!("{}", toml::to_string(&cfg).unwrap()); - }, + CliAction::Default => println!("{}", toml::to_string(&Config::default()).unwrap()), CliAction::Review { batch } => { + use std::io::Write; sqlx::any::install_default_drivers(); // must install all available drivers before connecting + if_using_sqlite_driver_and_file_is_missing_create_it_beforehand(&args.db); let storage = StorageProvider::connect(&args.db, ConfigOverrides::default()).await.unwrap(); let mut offset = 0; let mut buffer = String::new(); @@ -95,8 +93,6 @@ async fn main() { println!("* done"); }, CliAction::Serve { addr, config } => { - let addr : SocketAddr = addr.parse().expect("invalid host provided"); - let config = match config { None => Config::default(), Some(path) => { @@ -106,9 +102,10 @@ async fn main() { }; sqlx::any::install_default_drivers(); // must install all available drivers before connecting + if_using_sqlite_driver_and_file_is_missing_create_it_beforehand(&args.db); let storage = StorageProvider::connect(&args.db, config.overrides).await.unwrap(); - let mut state = Context::new(storage); + let mut state = Context::new(storage, #[cfg(feature = "web")] config.template); for notifier in config.notifiers.providers { match notifier { @@ -142,12 +139,28 @@ async fn main() { let router = routes::create_router_with_app_routes(state); - tracing::info!("listening on {}", addr); + tracing::info!("serving on http://{}/", addr); - axum::Server::bind(&addr) - .serve(router.into_make_service()) + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + + axum::serve(listener, router) .await .unwrap(); } } } + +// it drives me nuts that it doesn't do it by default!!! is there an option? +fn if_using_sqlite_driver_and_file_is_missing_create_it_beforehand(uri: &str) { + use std::str::FromStr; + if !uri.starts_with("sqlite://") { return }; + let path = uri.replace("sqlite://", ""); + if let Ok(p) = std::path::PathBuf::from_str(&path) { + if !p.is_file() { + if let Err(e) = std::fs::File::create(p) { + tracing::warn!("could not create sqlite database file at {} : {}", path, e); + } + } + } + +} diff --git a/src/routes.rs b/src/routes.rs index 4174f3a..36344ac 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,25 +1,55 @@ use std::sync::Arc; -use axum::{Json, Form, Router, routing::{put, post, get}, extract::{State, Query}, response::Redirect}; +use axum::{Json, Form, Router, routing::{put, post, get}, extract::{State, Query}, response::{Redirect, Html}}; +use axum_extra::response::{Css, JavaScript}; -use crate::{notifications::NotificationProcessor, model::{Page, PageOptions, PageInsertion, PageView}, storage::StorageProvider}; +use crate::{notifications::NotificationProcessor, model::{Page, PageOptions, PageInsertion, PageView}, storage::StorageProvider, web::IndexTemplate}; pub fn create_router_with_app_routes(state: Context) -> Router { - Router::new() + let mut router = Router::new() .route("/api", get(get_suggestion)) .route("/api", post(send_suggestion_form)) - .route("/api", put(send_suggestion_json)) - .with_state(Arc::new(state)) + .route("/api", put(send_suggestion_json)); + + #[cfg(feature = "web")] + { + use sailfish::TemplateOnce; + let template = state.template.clone(); + router = router + .route("/style.css", get(|| async { Css(crate::web::STATIC_CSS) })) + .route("/infiniscroll.js", get(|| async { JavaScript(crate::web::STATIC_JS) })) + .route("/", get(|| async move { + match IndexTemplate::from(&template).render_once() { + Ok(txt) => Ok(Html(txt)), + Err(e) => Err(( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + format!("could not render template: {}", e) + )), + } + })); + } + + router.with_state(Arc::new(state)) } pub struct Context { providers: Vec>>, storage: StorageProvider, + + #[cfg(feature = "web")] + template: crate::config::ConfigTemplate, } impl Context { - pub fn new(storage: StorageProvider) -> Self { - Context { providers: Vec::new(), storage } + pub fn new( + storage: StorageProvider, + #[cfg(feature = "web")] template: crate::config::ConfigTemplate, + ) -> Self { + Context { + providers: Vec::new(), + storage, + #[cfg(feature = "web")] template, + } } pub fn register(&mut self, notifier: Box>) { diff --git a/src/storage.rs b/src/storage.rs index 69a20c2..3178e89 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -86,10 +86,10 @@ impl StorageProvider { ) } - pub async fn extract(&self, offset: i32, window: i32, public: bool) -> sqlx::Result> { + pub async fn extract(&self, offset: i32, limit: i32, public: bool) -> sqlx::Result> { let out = sqlx::query_as::<_, Page>("SELECT * FROM pages WHERE public = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3") .bind(if public { 1 } else { 0 }) // TODO since AnyPool won't handle booleans we compare with an integer - .bind(window) + .bind(limit) .bind(offset) .fetch_all(&self.db) .await? diff --git a/src/web.rs b/src/web.rs new file mode 100644 index 0000000..c37a6eb --- /dev/null +++ b/src/web.rs @@ -0,0 +1,18 @@ +use sailfish::TemplateOnce; + +use crate::config::ConfigTemplate; + +pub const STATIC_CSS : &str = include_str!("../web/style.css"); +pub const STATIC_JS : &str = include_str!("../web/infiniscroll.js"); + +#[derive(Debug, TemplateOnce)] +#[template(path = "index.stpl")] +pub struct IndexTemplate<'a> { + root: &'a ConfigTemplate, +} + +impl<'a> From<&'a ConfigTemplate> for IndexTemplate<'a> { + fn from(value: &'a ConfigTemplate) -> Self { + IndexTemplate { root: value } + } +} diff --git a/web/index.stpl b/web/index.stpl new file mode 100644 index 0000000..a93b9c6 --- /dev/null +++ b/web/index.stpl @@ -0,0 +1,89 @@ + + + + <%= root.title %> + <% if let Some(href) = &root.canonical { %><% } %> + + + + + + + + +
+

<%= root.title %>

+ +

<%= root.description %>

+
+
+
+ + +
+
+ + + +
+
+
+ + + + + + + + +

+

guestbook.rs ~ made with <3 by alemi

+
+ + + + + + diff --git a/web/infiniscroll.js b/web/infiniscroll.js new file mode 100644 index 0000000..447e5af --- /dev/null +++ b/web/infiniscroll.js @@ -0,0 +1,72 @@ +/** + * @typedef GuestbookPage + * @type {Object} + * @property {number} id - unique page id + * @property {string} author - name of page author + * @property {string} body - main page body + * @property {string|undefined} contact - optional page author's contact + * @property {string|undefined} url - optional url to author's contact, if either mail or http link + * @property {string} avatar - unique hash (based on contact) to use for libravatar img href + * @property {Date} date - time of creation for page + * @property + */ + +/** + * Hook infinite scroll callback with element builder + * + * @param {(data:GuestbookPage)=>string} builder - function invoked to generate new elements, must return HTML string + * @param {Object} [opt] - configuration options for infinite scroll behavior + * @param {number} [opt.threshold] - from 0 to 1, percentage of scroll at which new fetch triggers (default 0.9) + * @param {number} [opt.debounce] - how many milliseconds to wait before processing new scroll events (default 250) + * @param {string} [opt.container_id] - html id of container element to inject new values into (default '#container') + * @param {string} [opt.api_url] - url for api calls (default '/api') + * @param {string} [opt.offset_arg] - how the offset/page argument is called in the api backend (default 'offset') + * @param {boolean} [opt.prefetch] - load first page immediately, before any scroll event is received (default true) + * @param {boolean} [opt.free_index] - wether backend api allows free indexing (?offset=17) or expects pagination (?page=1) (default true) + * + * @example + * hookInfiniteScroll( + * (data) => { return `
  • ${data.author}: ${data.body}
  • ` }, + * {threshold: 0.75, debounce: 1000, api_url: 'http://my.custom.backend/api'} + * ); + */ +export default function hookInfiniteScroll(builder, opt) { + if (opt === undefined) opt = {}; + // we could do it with less lines using || but it checks for truthy (e.g. would discard debounce 0) + // explicitly check for undefined to avoid this, it's done only once at the start anyway + let container_id = opt.container_id != undefined ? opt.container_id : "#container"; + let threshold = opt.threshold != undefined ? opt.threshold : 0.9; + let debounce = opt.debounce != undefined ? opt.debounce : 250; + let api_url = opt.api_url != undefined ? opt.api_url : "/api"; + let prefetch = opt.prefetch != undefined ? opt.prefetch : true; + let offset_arg = opt.offset_arg != undefined ? opt.offset_arg : "offset"; + let free_index = opt.free_index != undefined ? opt.free_index : true; + + let container = document.getElementById(container_id); + let last_activation = Date.now(); + let offset = 0; + + function scrollDepth() { + let html_tag = document.body.parentElement; + return html_tag.scrollTop / (html_tag.scrollHeight - html_tag.clientHeight) + } + + let callback = async (ev) => { + if (ev !== true && scrollDepth() < threshold) return; // not scrolled enough + if (ev !== true && Date.now() - last_activation < debounce) return; // triggering too fast + last_activation = Date.now(); + let response = await fetch(`${api_url}?${offset_arg}=${offset}`); + let replies = await response.json(); + if (replies.length == 0) { + // reached end, unregister self + return document.removeEventListener("scroll", callback); + } + offset += free_index ? replies.length : 1; // track how deep we went + for (let repl of replies) { + container.innerHTML += builder(repl); + } + }; + + document.addEventListener("scroll", callback); + if (prefetch) callback(true); // invoke once immediately +} diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..3fd8cc4 --- /dev/null +++ b/web/style.css @@ -0,0 +1,160 @@ +body { + background: var(--bg-primary); + color: var(--fg-primary); + font-family: sans-serif; +} + +a:link { + color: var(--accent); +} + +a:visited { + color: var(--accent-dark); +} + +a:hover { + color: var(--accent-light); +} + +a:active { + color: var(--accent-light); + font-weight: bold; +} + +h1 { + color: var(--accent); +} + +hr { + border-color: var(--accent); +} + +.center { + text-align: center; + width: 60%; + margin-left: auto; + margin-right: auto; +} + +table.center { + padding-left: 5em; + padding-right: 5em; +} + +.full-width { + width: 100%; +} + +.tiny { + font-size: .5em; +} + +img.avatar { + border-radius: 50%; + padding: .25em; + width: 2.25em; + height: 2.25em; + border: solid .125em var(--accent); +} + +a img.avatar:hover { + padding: .125em; + border: solid .25em var(--accent-light); +} + +span.author { + color: var(--fg-secondary) +} + +b.contact { + cursor: help; +} + +details > summary { + list-style: none; +} + +img.round-cover { + width: 45%; + border: solid 20px var(--accent); + padding: 20px; +} + +input, textarea { + background-color: var(--bg-secondary); + border: 1px solid var(--accent); + padding: .5em; + margin: .5em; + color: var(--fg-primary); +} + +textarea { + padding: 1em; +} + +textarea.input-fields { + width: 50%; +} + +input.input-fields { + width: 20%; +} + +input.input-button { + width: 6%; +} + +input:focus, +textarea:focus { + outline: 2px solid var(--accent); +} + +@media screen and (max-width: 1600px) { + img.pixar { + width: 60%; + border: solid 15px var(--accent); + } + table.center { + padding-left: 1em; + padding-right: 1em; + } + textarea.input-fields { + width: 60%; + } + + input.input-fields { + width: 22%; + } + + input.input-button { + width: 10%; + } +} + +@media screen and (max-width: 900px) { + .center { + width: 80%; + } +} + +@media screen and (max-width: 700px) { + img.pixar { + border: solid 10px var(--accent); + padding: 15px; + } + .center { + width: 100%; + } + textarea.input-fields { + width: 90%; + } + + input.input-fields { + width: 30%; + } + + input.input-button { + width: 15%; + } +} +