feat: integrated frontend

also fix `$ guestbook default` and refactored a little
This commit is contained in:
əlemi 2024-01-03 18:16:54 +01:00
parent 6fc27b47db
commit 6ad5bc86bb
Signed by: alemi
GPG key ID: A4895B84D311642C
10 changed files with 464 additions and 28 deletions

View file

@ -20,19 +20,19 @@ async-trait = "0.1.73"
html-escape = "0.2.13" html-escape = "0.2.13"
clap = { version = "4.4.6", features = ["derive"] } clap = { version = "4.4.6", features = ["derive"] }
tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] }
axum = "0.6.20" axum = "0.7.3"
# db providers # db providers
sqlx = { version = "0.7.3", features = ["runtime-tokio", "tls-rustls", "any"] } sqlx = { version = "0.7.3", features = ["runtime-tokio", "tls-rustls", "any"] }
# notification providers # notification providers
teloxide = { version = "0.12.2", features = ["macros"], optional = true } teloxide = { version = "0.12.2", features = ["macros"], optional = true }
mail-send = { version = "0.4.6", optional = true } mail-send = { version = "0.4.6", optional = true }
tokio-rustls = { version = "0.25.0", optional = true } tokio-rustls = { version = "0.25.0", optional = true }
# frontend
sailfish = { version = "0.8.3", optional = true }
axum-extra = "0.9.1"
[features] [features]
default = [ default = ["mysql", "sqlite", "postgres", "telegram", "email", "web"] # all features by default
"mysql", "sqlite", "postgres",
"telegram", "email"
]
# db drivers # db drivers
mysql = ["sqlx/mysql"] mysql = ["sqlx/mysql"]
sqlite = ["sqlx/sqlite"] sqlite = ["sqlx/sqlite"]
@ -40,3 +40,5 @@ postgres = ["sqlx/postgres"]
# notifier providers # notifier providers
telegram = ["dep:teloxide"] telegram = ["dep:teloxide"]
email = ["dep:mail-send", "dep:tokio-rustls"] email = ["dep:mail-send", "dep:tokio-rustls"]
# frontend
web = ["dep:sailfish"]

1
sailfish.toml Normal file
View file

@ -0,0 +1 @@
template_dirs = ["web"]

View file

@ -5,6 +5,8 @@ pub struct Config {
pub overrides: ConfigOverrides, pub overrides: ConfigOverrides,
pub notifiers: ConfigNotifiers, pub notifiers: ConfigNotifiers,
pub template: ConfigTemplate,
} }
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
@ -17,6 +19,55 @@ pub struct ConfigOverrides {
pub date: bool, 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<String>,
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)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ConfigNotifiers { pub struct ConfigNotifiers {
pub providers: Vec<NotifierProvider>, pub providers: Vec<NotifierProvider>,

View file

@ -1,4 +1,3 @@
use std::{net::SocketAddr, io::Write};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use config::ConfigOverrides; use config::ConfigOverrides;
@ -11,6 +10,9 @@ mod model;
mod storage; mod storage;
mod config; mod config;
#[cfg(feature = "web")]
mod web;
#[derive(Debug, Clone, Parser)] #[derive(Debug, Clone, Parser)]
#[command(author, version, about)] #[command(author, version, about)]
@ -62,15 +64,11 @@ async fn main() {
.init(); .init();
match args.action { match args.action {
CliAction::Default => { CliAction::Default => println!("{}", toml::to_string(&Config::default()).unwrap()),
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::Review { batch } => { CliAction::Review { batch } => {
use std::io::Write;
sqlx::any::install_default_drivers(); // must install all available drivers before connecting 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 storage = StorageProvider::connect(&args.db, ConfigOverrides::default()).await.unwrap();
let mut offset = 0; let mut offset = 0;
let mut buffer = String::new(); let mut buffer = String::new();
@ -95,8 +93,6 @@ async fn main() {
println!("* done"); println!("* done");
}, },
CliAction::Serve { addr, config } => { CliAction::Serve { addr, config } => {
let addr : SocketAddr = addr.parse().expect("invalid host provided");
let config = match config { let config = match config {
None => Config::default(), None => Config::default(),
Some(path) => { Some(path) => {
@ -106,9 +102,10 @@ async fn main() {
}; };
sqlx::any::install_default_drivers(); // must install all available drivers before connecting 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 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 { for notifier in config.notifiers.providers {
match notifier { match notifier {
@ -142,12 +139,28 @@ async fn main() {
let router = routes::create_router_with_app_routes(state); let router = routes::create_router_with_app_routes(state);
tracing::info!("listening on {}", addr); tracing::info!("serving on http://{}/", addr);
axum::Server::bind(&addr) let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
.serve(router.into_make_service())
axum::serve(listener, router)
.await .await
.unwrap(); .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);
}
}
}
}

View file

@ -1,25 +1,55 @@
use std::sync::Arc; 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 { pub fn create_router_with_app_routes(state: Context) -> Router {
Router::new() let mut router = Router::new()
.route("/api", get(get_suggestion)) .route("/api", get(get_suggestion))
.route("/api", post(send_suggestion_form)) .route("/api", post(send_suggestion_form))
.route("/api", put(send_suggestion_json)) .route("/api", put(send_suggestion_json));
.with_state(Arc::new(state))
#[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 { pub struct Context {
providers: Vec<Box<dyn NotificationProcessor<Page>>>, providers: Vec<Box<dyn NotificationProcessor<Page>>>,
storage: StorageProvider, storage: StorageProvider,
#[cfg(feature = "web")]
template: crate::config::ConfigTemplate,
} }
impl Context { impl Context {
pub fn new(storage: StorageProvider) -> Self { pub fn new(
Context { providers: Vec::new(), storage } 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<dyn NotificationProcessor<Page>>) { pub fn register(&mut self, notifier: Box<dyn NotificationProcessor<Page>>) {

View file

@ -86,10 +86,10 @@ impl StorageProvider {
) )
} }
pub async fn extract(&self, offset: i32, window: i32, public: bool) -> sqlx::Result<Vec<PageView>> { pub async fn extract(&self, offset: i32, limit: i32, public: bool) -> sqlx::Result<Vec<PageView>> {
let out = sqlx::query_as::<_, Page>("SELECT * FROM pages WHERE public = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3") 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(if public { 1 } else { 0 }) // TODO since AnyPool won't handle booleans we compare with an integer
.bind(window) .bind(limit)
.bind(offset) .bind(offset)
.fetch_all(&self.db) .fetch_all(&self.db)
.await? .await?

18
src/web.rs Normal file
View file

@ -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 }
}
}

89
web/index.stpl Normal file
View file

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html>
<head>
<title><%= root.title %></title>
<% if let Some(href) = &root.canonical { %><link rel="canonical" href="<%= href %>"><% } %>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta property="og:type" content="website">
<meta property="og:title" content="<%= root.title %>">
<meta property="og:description" content="<%= root.description %>">
<style>
:root {
--bg-primary: <%= root.palette.bg_main %>;
--bg-secondary: <%= root.palette.bg_off %>;
--fg-primary: <%= root.palette.fg_main %>;
--fg-secondary: <%= root.palette.fg_off %>;
--accent: <%= root.palette.accent %>;
--accent-dark: <%= root.palette.accent_dark %>;
--accent-light: <%= root.palette.accent_light %>;
}
</style>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="center">
<h1><%= root.title %></h1>
<img src="<%= root.logo %>" class="round-cover" title="guestbook cover">
<p><%= root.description %></p>
<div>
<form action="/api" method="post">
<div class="full-width">
<!-- TODO can i get this textarea to be full width without weird magic percentages?? -->
<textarea class="input-fields" name="body" placeholder="<%= root.placeholder_body %>" title="main message body, html will be escaped" required></textarea>
</div>
<div class="full-width">
<input class="input-fields" type="text" name="author" maxlength="25" placeholder="your name" title="name to show next to your comment" required>
<input class="input-fields" type="text" name="contact" maxlength="50" title="email or website, optional, used for avatar (libravatar.org)" placeholder="contact (optional)">
<input class="input-button" type="submit" value="send!">
</div>
</form>
</div>
<table id="#container" class="center" style="margin-top: 3em" cellspacing="0">
<tr>
<td style="width: 20%"></td>
<td style="width: 80%"></td>
</tr>
<tr>
<td colspan="2"><hr></td>
</tr>
</table>
<p class="tiny" style="margin-top: 5em"><b>guestbook.rs</b> ~ made with &lt;3 by <a href="mailto:me@alemi.dev">alemi</a></p>
</div>
<script type="module" src="/infiniscroll.js"></script>
<script type="module" language="javascript">
import hookInfiniteScroll from "./infiniscroll.js";
hookInfiniteScroll((repl) => {
return `
<tr>
<td>
${repl.url ? '<a href="' + repl.url + '">' : ''}
<img class="avatar" src="https://seccdn.libravatar.org/avatar/${repl.avatar}?s=150&default=identicon">
${repl.url ? '</a>' : '' }
</td>
<td rowspan="2">
<p style="text-align: left">${repl.body}</p>
<p class="tiny" style="text-align: right">${(new Date(repl.date)).toLocaleString()}</p>
</td>
</tr>
<tr>
<td>
<details>
<summary>
${repl.contact ? '<b class="contact">' : ''}
<span class="author">${repl.author}</span>
${repl.contact ? '</b>' : ''}
</summary>
<i class="tiny">${repl.contact ? repl.contact : ''}</i>
</details>
</td>
</tr>
<tr>
<td colspan="2"><hr></td>
</tr>`
});
</script>
</body>
</html>

72
web/infiniscroll.js Normal file
View file

@ -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 `<li><b>${data.author}</b>: ${data.body}</li>` },
* {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
}

160
web/style.css Normal file
View file

@ -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%;
}
}