mirror of
https://git.alemi.dev/guestbook.rs.git
synced 2024-12-18 18:54:51 +01:00
feat: integrated frontend
also fix `$ guestbook default` and refactored a little
This commit is contained in:
parent
6fc27b47db
commit
6ad5bc86bb
10 changed files with 464 additions and 28 deletions
12
Cargo.toml
12
Cargo.toml
|
@ -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
1
sailfish.toml
Normal file
|
@ -0,0 +1 @@
|
||||||
|
template_dirs = ["web"]
|
|
@ -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>,
|
||||||
|
|
41
src/main.rs
41
src/main.rs
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -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>>) {
|
||||||
|
|
|
@ -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
18
src/web.rs
Normal 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
89
web/index.stpl
Normal 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 <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
72
web/infiniscroll.js
Normal 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
160
web/style.css
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue