From 139b7ec10bc7f08dae9bd57eef8eff73fbb22061 Mon Sep 17 00:00:00 2001 From: Vika Date: Sat, 7 May 2022 20:28:43 +0300 Subject: Split into different crates Templates and utility types are now separate crates to speed up compilation, linting and potential reuse/replacement. Potentially more crates could be split out/modularized, resulting in speedups, smaller binaries (whenever features are excluded) and even more reuse capabilities. --- Cargo.lock | 23 +- Cargo.toml | 13 +- src/database/mod.rs | 10 +- src/frontend/login.rs | 19 +- src/frontend/mod.rs | 21 +- src/frontend/templates/mod.rs | 473 ----------------------------------- src/frontend/templates/onboarding.rs | 192 -------------- src/lib.rs | 9 - templates/Cargo.toml | 19 ++ templates/src/lib.rs | 6 + templates/src/login.rs | 17 ++ templates/src/onboarding.rs | 192 ++++++++++++++ templates/src/templates.rs | 471 ++++++++++++++++++++++++++++++++++ util/Cargo.toml | 12 + util/src/lib.rs | 18 ++ 15 files changed, 776 insertions(+), 719 deletions(-) delete mode 100644 src/frontend/templates/mod.rs delete mode 100644 src/frontend/templates/onboarding.rs create mode 100644 templates/Cargo.toml create mode 100644 templates/src/lib.rs create mode 100644 templates/src/login.rs create mode 100644 templates/src/onboarding.rs create mode 100644 templates/src/templates.rs create mode 100644 util/Cargo.toml create mode 100644 util/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index acf370d..40a3401 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1571,7 +1571,6 @@ dependencies = [ "data-encoding", "easy-scraper", "either", - "ellipse", "env_logger 0.8.4", "futures", "futures-util", @@ -1579,6 +1578,8 @@ dependencies = [ "httpmock", "hyper", "hyper-rustls", + "kittybox-templates", + "kittybox-util", "lazy_static", "listenfd", "log 0.4.17", @@ -1608,6 +1609,26 @@ dependencies = [ "warp-prometheus", ] +[[package]] +name = "kittybox-templates" +version = "0.1.0" +dependencies = [ + "chrono", + "ellipse", + "http", + "kittybox-util", + "log 0.4.17", + "markup", + "serde_json", +] + +[[package]] +name = "kittybox-util" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "kuchiki" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index f8dbd6a..eaaac7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,16 @@ name = "kittybox-database-converter" path = "src/bin/kittybox_database_converter.rs" required-features = ["util", "redis"] +[workspace] +members = [".", "./util", "./templates"] +default-members = [".", "./util", "./templates"] +[dependencies.kittybox-util] +version = "0.1.0" +path = "./util" +[dependencies.kittybox-templates] +version = "0.1.0" +path = "./templates" + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dev-dependencies] @@ -39,7 +49,6 @@ bytes = "^1.1.0" data-encoding = "^2.3.2" # Efficient and customizable data-encoding functions like base64, base32, and hex easy-scraper = "^0.2.0" # HTML scraping library focused on ease of use either = "^1.6.1" # A general purpose sum type with two cases -ellipse = "^0.2.0" # Truncate and ellipsize strings in a human-friendly way env_logger = "^0.8.3" # A logging implementation for `log` which is configured via an environment variable futures = "^0.3.14" # An implementation of futures and streams futures-util = "^0.3.14" # Common utilities and extension traits for the futures-rs library @@ -102,4 +111,4 @@ features = ["webpki-tokio", "http1", "http2", "tls12", "logging"] [dependencies.reqwest] version = "^0.11.10" default-features = false -features = ["rustls-tls-webpki-roots", "gzip", "brotli", "json", "stream"] +features = ["rustls-tls-webpki-roots", "gzip", "brotli", "json", "stream"] \ No newline at end of file diff --git a/src/database/mod.rs b/src/database/mod.rs index 0d98dd4..5a1dd3f 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -9,15 +9,7 @@ mod memory; #[cfg(test)] pub use crate::database::memory::MemoryStorage; - -/// Data structure representing a Micropub channel in the ?q=channels output. -#[derive(Serialize, Deserialize, PartialEq, Debug)] -pub struct MicropubChannel { - /// The channel's UID. It is usually also a publically accessible permalink URL. - pub uid: String, - /// The channel's user-friendly name used to recognize it in lists. - pub name: String, -} +pub use kittybox_util::MicropubChannel; /// Enum representing different errors that might occur during the database query. #[derive(Debug, Clone, Copy)] diff --git a/src/frontend/login.rs b/src/frontend/login.rs index 35ce3db..9665ce7 100644 --- a/src/frontend/login.rs +++ b/src/frontend/login.rs @@ -9,24 +9,7 @@ use std::str::FromStr; use crate::frontend::templates::Template; use crate::frontend::{FrontendError, IndiewebEndpoints}; use crate::{database::Storage, ApplicationState}; - -markup::define! { - LoginPage { - form[method="POST"] { - h1 { "Sign in with your website" } - p { - "Signing in to Kittybox might allow you to view private content " - "intended for your eyes only." - } - - section { - label[for="url"] { "Your website URL" } - input[id="url", name="url", placeholder="https://example.com/"]; - input[type="submit"]; - } - } - } -} +use kittybox_templates::LoginPage; pub async fn form(req: Request>) -> Result { let owner = req.url().origin().ascii_serialization() + "/"; diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index daeebd9..106d839 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -4,21 +4,12 @@ use serde::{Deserialize, Serialize}; use futures_util::TryFutureExt; use warp::{http::StatusCode, Filter, host::Authority, path::FullPath}; -static POSTS_PER_PAGE: usize = 20; - //pub mod login; -mod templates; #[allow(unused_imports)] -use templates::{ErrorPage, MainPage, OnboardingPage, Template}; - -#[derive(Clone, Serialize, Deserialize)] -pub struct IndiewebEndpoints { - pub authorization_endpoint: String, - pub token_endpoint: String, - pub webmention: Option, - pub microsub: Option, -} +use kittybox_templates::{ErrorPage, MainPage, OnboardingPage, Template, POSTS_PER_PAGE}; + +pub use kittybox_util::IndiewebEndpoints; #[derive(Deserialize)] struct QueryParams { @@ -364,17 +355,17 @@ pub fn catchall(db: D, endpoints: IndiewebEndpoints) -> impl Filter< { Some("h-entry") => Ok(( post_name.unwrap_or("Note").to_string(), - templates::Entry { post: &post }.to_string(), + kittybox_templates::Entry { post: &post }.to_string(), StatusCode::OK )), Some("h-card") => Ok(( post_name.unwrap_or("Contact card").to_string(), - templates::VCard { card: &post }.to_string(), + kittybox_templates::VCard { card: &post }.to_string(), StatusCode::OK )), Some("h-feed") => Ok(( post_name.unwrap_or("Feed").to_string(), - templates::Feed { feed: &post }.to_string(), + kittybox_templates::Feed { feed: &post }.to_string(), StatusCode::OK )), _ => Err(warp::reject::custom(FrontendError::with_code( diff --git a/src/frontend/templates/mod.rs b/src/frontend/templates/mod.rs deleted file mode 100644 index 1f7ac6a..0000000 --- a/src/frontend/templates/mod.rs +++ /dev/null @@ -1,473 +0,0 @@ -use crate::database::MicropubChannel; -use crate::frontend::IndiewebEndpoints; -use ellipse::Ellipse; -use warp::http::StatusCode; -use log::error; - -/// Return a pretty location specifier from a geo: URI. -fn decode_geo_uri(uri: &str) -> String { - if let Some(part) = uri.split(':').collect::>().get(1) { - if let Some(part) = part.split(';').next() { - let mut parts = part.split(','); - let lat = parts.next().unwrap(); - let lon = parts.next().unwrap(); - // TODO - format them as proper latitude and longitude - return format!("{}, {}", lat, lon); - } else { - uri.to_string() - } - } else { - uri.to_string() - } -} - -mod onboarding; -pub use onboarding::OnboardingPage; - -markup::define! { - Template<'a>(title: &'a str, blog_name: &'a str, endpoints: Option, feeds: Vec, user: Option, content: String) { - @markup::doctype() - html { - head { - title { @title } - link[rel="preconnect", href="https://fonts.gstatic.com"]; - link[rel="stylesheet", href="/static/style.css"]; - meta[name="viewport", content="initial-scale=1, width=device-width"]; - // TODO: link rel= for common IndieWeb APIs: webmention, microsub - link[rel="micropub", href="/micropub"]; // Static, because it's built into the server itself - @if let Some(endpoints) = endpoints { - link[rel="authorization_endpoint", href=&endpoints.authorization_endpoint]; - link[rel="token_endpoint", href=&endpoints.token_endpoint]; - @if let Some(webmention) = &endpoints.webmention { - link[rel="webmention", href=&webmention]; - } - @if let Some(microsub) = &endpoints.microsub { - link[rel="microsub", href=µsub]; - } - } - } - body { - // TODO Somehow compress headerbar into a menu when the screen space is tight - nav #headerbar { - ul { - li { a #homepage[href="/"] { @blog_name } } - @for feed in feeds.iter() { - li { a[href=&feed.uid] { @feed.name } } - } - li.shiftright { - @if user.is_none() { - a #login[href="/login"] { "Sign in" } - } else { - span { - @user.as_ref().unwrap() " - " a #logout[href="/logout"] { "Sign out" } - } - } - } - } - } - main { - @markup::raw(content) - } - } - } - } - Entry<'a>(post: &'a serde_json::Value) { - article."h-entry" { - header.metadata { - @if post["properties"]["name"][0].is_string() { - h1."p-name" { - @post["properties"]["name"][0].as_str().unwrap() - } - } else { - @if post["properties"]["author"][0].is_object() { - section."mini-h-card" { - a.larger[href=post["properties"]["author"][0]["properties"]["uid"][0].as_str().unwrap()] { - @if post["properties"]["author"][0]["properties"]["photo"][0].is_string() { - img[src=post["properties"]["author"][0]["properties"]["photo"][0].as_str().unwrap()] {} - } - @post["properties"]["author"][0]["properties"]["name"][0].as_str().unwrap() - } - } - } - } - div { - span { - a."u-url"."u-uid"[href=post["properties"]["uid"][0].as_str().unwrap()] { - time."dt-published"[datetime=post["properties"]["published"][0].as_str().unwrap()] { - @chrono::DateTime::parse_from_rfc3339(post["properties"]["published"][0].as_str().unwrap()) - .map(|dt| dt.format("%a %b %e %T %Y").to_string()) - .unwrap_or("ERROR: Couldn't parse the datetime".to_string()) - } - } - } - @if post["properties"]["visibility"][0].as_str().unwrap_or("public") != "public" { - span."p-visibility"[value=post["properties"]["visibility"][0].as_str().unwrap()] { - @post["properties"]["visibility"][0].as_str().unwrap() - } - } - @if post["properties"]["category"].is_array() { - span { - ul.categories { - "Tagged: " - @for cat in post["properties"]["category"].as_array().unwrap() { - li."p-category" { @cat.as_str().unwrap() } - } - } - } - } - @if post["properties"]["in-reply-to"].is_array() { - // TODO: Rich reply contexts - blocked on MF2 parser - span { - "In reply to: " - ul.replyctx { - @for ctx in post["properties"]["in-reply-to"].as_array().unwrap() { - li { a."u-in-reply-to"[href=ctx.as_str().unwrap()] { - @ctx.as_str().unwrap().truncate_ellipse(24).as_ref() - } } - } - } - } - } - } - @if post["properties"]["url"].as_array().unwrap().len() > 1 { - hr; - ul { - "Pretty permalinks for this post:" - @for url in post["properties"]["url"].as_array().unwrap().iter().filter(|i| **i != post["properties"]["uid"][0]).map(|i| i.as_str().unwrap()) { - li { - a."u-url"[href=url] { @url } - } - } - } - } - @if post["properties"]["location"].is_array() || post["properties"]["checkin"].is_array() { - div { - @if post["properties"]["checkin"].is_array() { - span { - "Check-in to: " - @if post["properties"]["checkin"][0].is_string() { - // It's a URL - a."u-checkin"[href=post["properties"]["checkin"][0].as_str().unwrap()] { - @post["properties"]["checkin"][0].as_str().unwrap().truncate_ellipse(24).as_ref() - } - } else { - a."u-checkin"[href=post["properties"]["checkin"][0]["properties"]["uid"][0].as_str().unwrap()] { - @post["properties"]["checkin"][0]["properties"]["name"][0].as_str().unwrap() - } - } - } - } - @if post["properties"]["location"].is_array() { - span { - "Location: " - @if post["properties"]["location"][0].is_string() { - // It's a geo: URL - // We need to decode it - a."u-location"[href=post["properties"]["location"][0].as_str().unwrap()] { - @decode_geo_uri(post["properties"]["location"][0].as_str().unwrap()) - } - } else { - // It's an inner h-geo object - a."u-location"[href=post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap()))] { - // I'm a lazy bitch - @decode_geo_uri(&post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap()))) - } - } - } - } - } - } - @if post["properties"]["ate"].is_array() || post["properties"]["drank"].is_array() { - div { - @if post["properties"]["ate"].is_array() { - span { ul { - "Ate:" - @for food in post["properties"]["ate"].as_array().unwrap() { - li { - @if food.is_string() { - // If this is a string, it's a URL. - a."u-ate"[href=food.as_str().unwrap()] { - @food.as_str().unwrap().truncate_ellipse(24).as_ref() - } - } else { - // This is a rich food object (mm, sounds tasty! I wanna eat something tasty) - a."u-ate"[href=food["properties"]["uid"][0].as_str().unwrap_or("#")] { - @food["properties"]["name"][0].as_str() - .unwrap_or(food["properties"]["uid"][0].as_str().unwrap_or("#").truncate_ellipse(24).as_ref()) - } - } - } - } - } } - } - @if post["properties"]["drank"].is_array() { - span { ul { - "Drank:" - @for food in post["properties"]["drank"].as_array().unwrap() { - li { - @if food.is_string() { - // If this is a string, it's a URL. - a."u-drank"[href=food.as_str().unwrap()] { - @food.as_str().unwrap().truncate_ellipse(24).as_ref() - } - } else { - // This is a rich food object (mm, sounds tasty! I wanna eat something tasty) - a."u-drank"[href=food["properties"]["uid"][0].as_str().unwrap_or("#")] { - @food["properties"]["name"][0].as_str() - .unwrap_or(food["properties"]["uid"][0].as_str().unwrap_or("#").truncate_ellipse(24).as_ref()) - } - } - } - } - } } - } - } - } - } - @PhotoGallery { photos: post["properties"]["photo"].as_array() } - @if post["properties"]["content"][0]["html"].is_string() { - main."e-content" { - @markup::raw(post["properties"]["content"][0]["html"].as_str().unwrap().trim()) - } - } - @WebInteractions { post } - } - } - PhotoGallery<'a>(photos: Option<&'a Vec>) { - @if photos.is_some() { - @for photo in photos.unwrap() { - @if photo.is_string() { - img."u-photo"[src=photo.as_str().unwrap(), loading="lazy"]; - } else if photo.is_array() { - @if photo["thumbnail"].is_string() { - a."u-photo"[href=photo["value"].as_str().unwrap()] { - img[src=photo["thumbnail"].as_str().unwrap(), loading="lazy", alt=photo["alt"].as_str().unwrap_or("")]; - } - } else { - img."u-photo"[src=photo["value"].as_str().unwrap(), loading="lazy", alt=photo["alt"].as_str().unwrap_or("")]; - } - } - } - } - } - WebInteractions<'a>(post: &'a serde_json::Value) { - footer.webinteractions { - ul.counters { - li { - span.icon { "❤️" } - span.counter { @post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0) } - } - li { - span.icon { "💬" } - span.counter { @post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0) } - } - li { - span.icon { "🔄" } - span.counter { @post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0) } - } - li { - span.icon { "🔖" } - span.counter { @post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0) } - } - } - // Needs rich webmention support which may or may not depend on an MF2 parser - // Might circumvent with an external parser with CORS support - // why write this stuff in rust then tho - /*details { - summary { "Show comments and reactions" } - @if post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0) > 0 { - // Show a facepile of likes for a post - } - @if post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0) > 0 { - // Show a facepile of bookmarks for a post - } - @if post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0) > 0 { - // Show a facepile of reposts for a post - } - @if post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0) > 0 { - // Show all the comments recursively (so we could do Salmention with them) - } - }*/ - } - } - VCard<'a>(card: &'a serde_json::Value) { - article."h-card" { - @if card["properties"]["photo"][0].is_string() { - img."u-photo"[src=card["properties"]["photo"][0].as_str().unwrap()]; - } - h1 { - a."u-url"."u-uid"."p-name"[href=card["properties"]["uid"][0].as_str().unwrap()] { - @card["properties"]["name"][0].as_str().unwrap() - } - } - @if card["properties"]["pronoun"].is_array() { - span { - "(" - @for (i, pronoun) in card["properties"]["pronoun"].as_array().unwrap().iter().filter_map(|v| v.as_str()).enumerate() { - span."p-pronoun" { - @pronoun - } - // Insert commas between multiple sets of pronouns - @if i < (card["properties"]["pronoun"].as_array().unwrap().len() - 1) {", "} - } - ")" - } - } - @if card["properties"]["note"].is_array() { - p."p-note" { - @card["properties"]["note"][0]["value"].as_str().unwrap_or_else(|| card["properties"]["note"][0].as_str().unwrap()) - } - } - @if card["properties"]["url"].is_array() { - ul { - "Can be found elsewhere at:" - @for url in card["properties"]["url"].as_array().unwrap().iter().filter_map(|v| v.as_str()).filter(|v| v != &card["properties"]["uid"][0].as_str().unwrap()).filter(|v| !v.starts_with(&card["properties"]["author"][0].as_str().unwrap())) { - li { a."u-url"[href=url, rel="me"] { @url } } - } - } - } - } - } - Food<'a>(food: &'a serde_json::Value) { - article."h-food" { - header.metadata { - h1 { - a."p-name"."u-url"[href=food["properties"]["url"][0].as_str().unwrap()] { - @food["properties"]["name"][0].as_str().unwrap() - } - } - } - @PhotoGallery { photos: food["properties"]["photo"].as_array() } - } - } - Feed<'a>(feed: &'a serde_json::Value) { - div."h-feed" { - div.metadata { - @if feed["properties"]["name"][0].is_string() { - h1."p-name".titanic { - a[href=feed["properties"]["uid"][0].as_str().unwrap(), rel="feed"] { - @feed["properties"]["name"][0].as_str().unwrap() - } - } - } - } - @if feed["children"].is_array() { - @for child in feed["children"].as_array().unwrap() { - @match child["type"][0].as_str().unwrap() { - "h-entry" => { @Entry { post: child } } - "h-feed" => { @Feed { feed: child } } - "h-event" => { - @{error!("Templating error: h-events aren't implemented yet");} - } - "h-card" => { @VCard { card: child }} - something_else => { - @{error!("Templating error: found a {} object that couldn't be parsed", something_else);} - } - } - } - } - @if feed["children"].as_array().map(|a| a.len()).unwrap_or(0) == 0 { - p { - "Looks like you reached the end. Wanna jump back to the " - a[href=feed["properties"]["uid"][0].as_str().unwrap()] { - "beginning" - } "?" - } - } - @if feed["children"].as_array().map(|a| a.len()).unwrap_or(0) == super::POSTS_PER_PAGE { - a[rel="prev", href=feed["properties"]["uid"][0].as_str().unwrap().to_string() - + "?after=" + feed["children"][super::POSTS_PER_PAGE - 1]["properties"]["uid"][0].as_str().unwrap()] { - "Older posts" - } - } - } - } - MainPage<'a>(feed: &'a serde_json::Value, card: &'a serde_json::Value) { - .sidebyside { - @VCard { card } - #dynamicstuff { - p { "This section will provide interesting statistics or tidbits about my life in this exact moment (with maybe a small delay)." } - p { "It will probably require JavaScript to self-update, but I promise to keep this widget lightweight and open-source!" } - p { small { - "JavaScript isn't a menace, stop fearing it or I will switch to WebAssembly " - "and knock your nico-nico-kneecaps so fast with its speed you won't even notice that... " - small { "omae ha mou shindeiru" } - @markup::raw("") - } } - } - } - @Feed { feed } - } - ErrorPage(code: StatusCode, msg: Option) { - h1 { @format!("HTTP {}", code) } - @match *code { - StatusCode::UNAUTHORIZED => { - p { "Looks like you need to authenticate yourself before seeing this page. Try logging in with IndieAuth using the Login button above!" } - } - StatusCode::FORBIDDEN => { - p { "Looks like you're forbidden from viewing this page." } - p { - "This might've been caused by being banned from viewing my website" - "or simply by trying to see what you're not supposed to see, " - "like a private post that's not intended for you. It's ok, it happens." - } - } - StatusCode::GONE => { - p { "Looks like the page you're trying to find is gone and is never coming back." } - } - StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS => { - p { "The page is there, but I can't legally provide it to you because the censorship said so." } - } - StatusCode::NOT_FOUND => { - p { "Looks like there's no such page. Maybe you or someone else mistyped a URL or my database experienced data loss." } - } - StatusCode::IM_A_TEAPOT => { - p { "Wait, do you seriously expect my website to brew you coffee? It's not a coffee machine!" } - - p { - small { - "I could brew you some coffee tho if we meet one day... " - small { - i { - "i-it's nothing personal, I just like brewing coffee, b-baka!!!~ >. { - @match msg { - None => { - p { - "There was an undescribed error in your request. " - "Please try again later or with a different request." - } - } - Some(msg) => { - p { - "There was a following error in your request:" - } - blockquote { pre { @msg } } - } - } - } - StatusCode::INTERNAL_SERVER_ERROR => { - @match msg { - None => { - p { "It seems like you have found an error. Not to worry, it has already been logged." } - } - Some(msg) => { - p { "The server encountered an error while processing your request:" } - blockquote { @msg } - p { "Don't worry, it has already been logged." } - } - } - } - _ => { - p { "It seems like you have found an error. Not to worry, it has already been logged." } - } - } - P { "For now, may I suggest to visit " a[href="/"] {"the main page"} " of this website?" } - - } -} diff --git a/src/frontend/templates/onboarding.rs b/src/frontend/templates/onboarding.rs deleted file mode 100644 index 9d0f2e1..0000000 --- a/src/frontend/templates/onboarding.rs +++ /dev/null @@ -1,192 +0,0 @@ -markup::define! { - OnboardingPage { - h1[style="text-align: center"] { - "Welcome to Kittybox" - } - script[type="module", src="/static/onboarding.js"] {} - link[rel="stylesheet", href="/static/onboarding.css"]; - form.onboarding[action="", method="POST"] { - noscript { - p { - "Ok, let's be honest. Most of this software doesn't require JS to be enabled " - "to view pages (and in some cases, even edit them if logged in)." - } - p { "This page is a little bit different. It uses JavaScript to provide interactive features, such as:" } - ul { - li { "Multiple-input questions" } - li { "Answers spanning multiple fields" } - li { "Preview of files being uploaded" } - li { "Pretty pagination so you won't feel overwhelmed" } - } - p { - "Sadly, it's very hard or even impossible to recreate this without any JavaScript. " - "Good news though - the code is " b { "open-source AND free software" } - " (under GNU AGPLv3) " - "and I promise to not obfuscate it or minify it. " - a[href="/static/onboarding.js"] { "Here" } - "'s the link - you can try reading it so you'll be 200% sure " - "it won't steal your cookies or turn your kitty into a soulless monster." - @markup::raw("") - } - hr; - p { "In other words: " b { "please enable JavaScript for this page to work properly." } small { "sorry T__T" } } - } - ul #progressbar[style="display: none"] { - li #intro { "Introduction" } - li #hcard { "Your profile" } - li #settings { "Your website" } - li #firstpost { "Your first post" } - } - fieldset #intro[style="display: none"] { - legend { "Introduction" } - p { - "Kittybox is a CMS that can act as a member of the IndieWeb. " - "IndieWeb is a global distributed social network built on top of open Web standards " - "and composed of blogs around the Internet supporting these standards." - } - p { "There is no registration or centralized database of any sort - everyone owns their data and is responsible for it." } - p { "If you're seeing this page, it looks like your configuration is correct and we can proceed with the setup." } - - div.switch_card_buttons { - button.switch_card.next_card[type="button", "data-card"="hcard"] { "Next" } - } - } - - fieldset #hcard[style="display: none"] { - legend { "Your profile" } - p { "An h-card is an IndieWeb social profile, and we're gonna make you one!" } - p { "Thanks to some clever markup, it will be readable by both humans and machines looking at your homepage."} - p { - "If you make a mistake, don't worry, you're gonna be able to edit this later." - "The only mandatory field is your name." - } - - div.form_group { - label[for="hcard_name"] { "Your name" } - input #hcard_name[name="hcard_name", placeholder="Your name"]; - small { - "No need to write the name as in your passport, this is not a legal document " - "- just write how you want to be called on the network. This name will be also " - "shown whenever you leave a comment on someone else's post using your website." - } - } - - div.form_group { - label[for="pronouns"] { "Your pronouns" } - div.multi_input #pronouns { - template { - input #hcard_pronouns[name="hcard_pronouns", placeholder="they/them?"]; - } - button.add_more[type="button", "aria-label"="Add more"] { "[+] Add more" } - } - small { - "Write which pronouns you use for yourself. It's a free-form field " - "so don't feel constrained - but keep it compact, as it'll be shown in a lot of places." - } - } - - div.form_group { - label[for="urls"] { "Links to other pages of you" } - div.multi_input #urls { - template { - input #hcard_url[name="hcard_url", placeholder="https://example.com/"]; - } - button.add_more[type="button", "aria-label"="Add more"] { "[+] Add more" } - } - small { - "These URLs will help your readers find you elsewhere and will help you that whoever owns these pages owns your website too" - " in case the links are mutual. So make sure to put a link to your site in your other social profiles!" - } - } - - div.form_group { - label[for="hcard_note"] { "A little about yourself" } - textarea #hcard_note[name="hcard_note", placeholder="Loves cooking, plants, cats, dogs and racoons."] {} - small { "A little bit of introduction. Just one paragraph, and note, you can't use HTML here (yet)." } - // TODO: HTML e-note instead of p-note - } - - // TODO: u-photo upload - needs media endpoint cooperation - - div.switch_card_buttons { - button.switch_card.prev_card[type="button", "data-card"="intro"] { "Previous" } - button.switch_card.next_card[type="button", "data-card"="settings"] { "Next" } - } - } - - fieldset #settings[style="display: none"] { - legend { "Your website" } - p { "Ok, it's nice to know you more. Tell me about what you'll be writing and how you want to name your blog." } - // TODO: site-name, saved to settings - - div.form_group { - label[for="blog_name"] { "Your website's name"} - input #blog_name[name="blog_name", placeholder="Kitty Box!"]; - small { "It'll get shown in the title of your blog, in the upper left corner!" } - } - - div.form_group { - label[for="custom_feeds"] { "Custom feeds" } - small { - p { - "You can set up custom feeds to post your stuff to. " - "This is a nice way to organize stuff into huge folders, like all your trips or your quantified-self data." - } - p { - "Feeds can be followed individually, which makes it easy for users who are interested in certain types " - "of content you produce to follow your adventures in certain areas of your life without cluttering their " - "readers." - } - p { - "We will automatically create some feeds for you aside from these so you won't have to - including a main feed, " - "address book (for venues you go to and people you talk about), a cookbook for your recipes and some more." - // TODO: Put a link to documentation explaining feeds in more detail. - } - } - div.multi_input #custom_feeds { - template { - fieldset.feed { - div.form_group { - label[for="feed_name"] { "Name" } - input #feed_name[name="feed_name", placeholder="My cool feed"]; - small { "This is a name that will identify this feed to the user. Make it short and descriptive!" } - } - div.form_group { - label[for="feed_slug"] { "Slug" } - input #feed_slug[name="feed_slug", placeholder="my-cool-feed"]; - small { "This will form a pretty URL for the feed. For example: https://example.com/feeds/my-cool-feed" } - } - } - } - button.add_more[type="button", "aria-label"="Add more"] { "[+] Add More" } - } - } - - div.switch_card_buttons { - button.switch_card.prev_card[type="button", "data-card"="hcard"] { "Previous" } - button.switch_card.next_card[type="button", "data-card"="firstpost"] { "Next" } - } - } - - fieldset #firstpost[style="display: none"] { - legend { "Your first post" } - p { "Maybe you should start writing your first posts now. How about a short note?" } - p { "A note is a short-form post (not unlike a tweet - but without the actual character limit) that doesn't bear a title." } - p { - "Consider telling more about yourself, your skills and interests in this note " - @markup::raw("—") - " though you're free to write anything you want. (By the way, you can use " - a[href="https://daringfireball.net/projects/markdown/syntax"] { "Markdown" } - " here to spice up your note!)" - } - - textarea #first_post_content[style="width: 100%; height: 8em", placeholder="Hello! I am really excited about #IndieWeb"] {} - - div.switch_card_buttons { - button.switch_card.prev_card[type="button", "data-card"="settings"] { "Previous" } - button[type="submit"] { "Finish" } - } - } - } - } -} diff --git a/src/lib.rs b/src/lib.rs index 2709022..9d4335a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,15 +31,6 @@ pub mod util { }) } - pub fn template( - template: R - ) -> impl warp::Reply - where - R: markup::Render + std::fmt::Display - { - warp::reply::html(template.to_string()) - } - pub fn parse_accept() -> impl Filter + Copy { warp::header::value("Accept").and_then(|accept: warp::http::HeaderValue| async move { let mut accept: http_types::content::Accept = { diff --git a/templates/Cargo.toml b/templates/Cargo.toml new file mode 100644 index 0000000..c4c7f46 --- /dev/null +++ b/templates/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "kittybox-templates" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +ellipse = "^0.2.0" # Truncate and ellipsize strings in a human-friendly way +http = "^0.2.7" # Hyper's strong HTTP types +log = "^0.4.14" # A lightweight logging facade for Rust +markup = "^0.12.0" # HTML templating engine +serde_json = "^1.0.64" # A JSON serialization file format +[dependencies.chrono] # Date and time library for Rust +version = "^0.4.19" +features = ["serde"] +[dependencies.kittybox-util] +version = "0.1.0" +path = "../util" \ No newline at end of file diff --git a/templates/src/lib.rs b/templates/src/lib.rs new file mode 100644 index 0000000..c7b03ea --- /dev/null +++ b/templates/src/lib.rs @@ -0,0 +1,6 @@ +mod templates; +pub use templates::{ErrorPage, MainPage, Template, POSTS_PER_PAGE, Entry, VCard, Feed}; +mod onboarding; +pub use onboarding::OnboardingPage; +mod login; +pub use login::LoginPage; diff --git a/templates/src/login.rs b/templates/src/login.rs new file mode 100644 index 0000000..042c308 --- /dev/null +++ b/templates/src/login.rs @@ -0,0 +1,17 @@ +markup::define! { + LoginPage { + form[method="POST"] { + h1 { "Sign in with your website" } + p { + "Signing in to Kittybox might allow you to view private content " + "intended for your eyes only." + } + + section { + label[for="url"] { "Your website URL" } + input[id="url", name="url", placeholder="https://example.com/"]; + input[type="submit"]; + } + } + } +} diff --git a/templates/src/onboarding.rs b/templates/src/onboarding.rs new file mode 100644 index 0000000..9d0f2e1 --- /dev/null +++ b/templates/src/onboarding.rs @@ -0,0 +1,192 @@ +markup::define! { + OnboardingPage { + h1[style="text-align: center"] { + "Welcome to Kittybox" + } + script[type="module", src="/static/onboarding.js"] {} + link[rel="stylesheet", href="/static/onboarding.css"]; + form.onboarding[action="", method="POST"] { + noscript { + p { + "Ok, let's be honest. Most of this software doesn't require JS to be enabled " + "to view pages (and in some cases, even edit them if logged in)." + } + p { "This page is a little bit different. It uses JavaScript to provide interactive features, such as:" } + ul { + li { "Multiple-input questions" } + li { "Answers spanning multiple fields" } + li { "Preview of files being uploaded" } + li { "Pretty pagination so you won't feel overwhelmed" } + } + p { + "Sadly, it's very hard or even impossible to recreate this without any JavaScript. " + "Good news though - the code is " b { "open-source AND free software" } + " (under GNU AGPLv3) " + "and I promise to not obfuscate it or minify it. " + a[href="/static/onboarding.js"] { "Here" } + "'s the link - you can try reading it so you'll be 200% sure " + "it won't steal your cookies or turn your kitty into a soulless monster." + @markup::raw("") + } + hr; + p { "In other words: " b { "please enable JavaScript for this page to work properly." } small { "sorry T__T" } } + } + ul #progressbar[style="display: none"] { + li #intro { "Introduction" } + li #hcard { "Your profile" } + li #settings { "Your website" } + li #firstpost { "Your first post" } + } + fieldset #intro[style="display: none"] { + legend { "Introduction" } + p { + "Kittybox is a CMS that can act as a member of the IndieWeb. " + "IndieWeb is a global distributed social network built on top of open Web standards " + "and composed of blogs around the Internet supporting these standards." + } + p { "There is no registration or centralized database of any sort - everyone owns their data and is responsible for it." } + p { "If you're seeing this page, it looks like your configuration is correct and we can proceed with the setup." } + + div.switch_card_buttons { + button.switch_card.next_card[type="button", "data-card"="hcard"] { "Next" } + } + } + + fieldset #hcard[style="display: none"] { + legend { "Your profile" } + p { "An h-card is an IndieWeb social profile, and we're gonna make you one!" } + p { "Thanks to some clever markup, it will be readable by both humans and machines looking at your homepage."} + p { + "If you make a mistake, don't worry, you're gonna be able to edit this later." + "The only mandatory field is your name." + } + + div.form_group { + label[for="hcard_name"] { "Your name" } + input #hcard_name[name="hcard_name", placeholder="Your name"]; + small { + "No need to write the name as in your passport, this is not a legal document " + "- just write how you want to be called on the network. This name will be also " + "shown whenever you leave a comment on someone else's post using your website." + } + } + + div.form_group { + label[for="pronouns"] { "Your pronouns" } + div.multi_input #pronouns { + template { + input #hcard_pronouns[name="hcard_pronouns", placeholder="they/them?"]; + } + button.add_more[type="button", "aria-label"="Add more"] { "[+] Add more" } + } + small { + "Write which pronouns you use for yourself. It's a free-form field " + "so don't feel constrained - but keep it compact, as it'll be shown in a lot of places." + } + } + + div.form_group { + label[for="urls"] { "Links to other pages of you" } + div.multi_input #urls { + template { + input #hcard_url[name="hcard_url", placeholder="https://example.com/"]; + } + button.add_more[type="button", "aria-label"="Add more"] { "[+] Add more" } + } + small { + "These URLs will help your readers find you elsewhere and will help you that whoever owns these pages owns your website too" + " in case the links are mutual. So make sure to put a link to your site in your other social profiles!" + } + } + + div.form_group { + label[for="hcard_note"] { "A little about yourself" } + textarea #hcard_note[name="hcard_note", placeholder="Loves cooking, plants, cats, dogs and racoons."] {} + small { "A little bit of introduction. Just one paragraph, and note, you can't use HTML here (yet)." } + // TODO: HTML e-note instead of p-note + } + + // TODO: u-photo upload - needs media endpoint cooperation + + div.switch_card_buttons { + button.switch_card.prev_card[type="button", "data-card"="intro"] { "Previous" } + button.switch_card.next_card[type="button", "data-card"="settings"] { "Next" } + } + } + + fieldset #settings[style="display: none"] { + legend { "Your website" } + p { "Ok, it's nice to know you more. Tell me about what you'll be writing and how you want to name your blog." } + // TODO: site-name, saved to settings + + div.form_group { + label[for="blog_name"] { "Your website's name"} + input #blog_name[name="blog_name", placeholder="Kitty Box!"]; + small { "It'll get shown in the title of your blog, in the upper left corner!" } + } + + div.form_group { + label[for="custom_feeds"] { "Custom feeds" } + small { + p { + "You can set up custom feeds to post your stuff to. " + "This is a nice way to organize stuff into huge folders, like all your trips or your quantified-self data." + } + p { + "Feeds can be followed individually, which makes it easy for users who are interested in certain types " + "of content you produce to follow your adventures in certain areas of your life without cluttering their " + "readers." + } + p { + "We will automatically create some feeds for you aside from these so you won't have to - including a main feed, " + "address book (for venues you go to and people you talk about), a cookbook for your recipes and some more." + // TODO: Put a link to documentation explaining feeds in more detail. + } + } + div.multi_input #custom_feeds { + template { + fieldset.feed { + div.form_group { + label[for="feed_name"] { "Name" } + input #feed_name[name="feed_name", placeholder="My cool feed"]; + small { "This is a name that will identify this feed to the user. Make it short and descriptive!" } + } + div.form_group { + label[for="feed_slug"] { "Slug" } + input #feed_slug[name="feed_slug", placeholder="my-cool-feed"]; + small { "This will form a pretty URL for the feed. For example: https://example.com/feeds/my-cool-feed" } + } + } + } + button.add_more[type="button", "aria-label"="Add more"] { "[+] Add More" } + } + } + + div.switch_card_buttons { + button.switch_card.prev_card[type="button", "data-card"="hcard"] { "Previous" } + button.switch_card.next_card[type="button", "data-card"="firstpost"] { "Next" } + } + } + + fieldset #firstpost[style="display: none"] { + legend { "Your first post" } + p { "Maybe you should start writing your first posts now. How about a short note?" } + p { "A note is a short-form post (not unlike a tweet - but without the actual character limit) that doesn't bear a title." } + p { + "Consider telling more about yourself, your skills and interests in this note " + @markup::raw("—") + " though you're free to write anything you want. (By the way, you can use " + a[href="https://daringfireball.net/projects/markdown/syntax"] { "Markdown" } + " here to spice up your note!)" + } + + textarea #first_post_content[style="width: 100%; height: 8em", placeholder="Hello! I am really excited about #IndieWeb"] {} + + div.switch_card_buttons { + button.switch_card.prev_card[type="button", "data-card"="settings"] { "Previous" } + button[type="submit"] { "Finish" } + } + } + } + } +} diff --git a/templates/src/templates.rs b/templates/src/templates.rs new file mode 100644 index 0000000..53b0965 --- /dev/null +++ b/templates/src/templates.rs @@ -0,0 +1,471 @@ +use kittybox_util::{MicropubChannel, IndiewebEndpoints}; +use ellipse::Ellipse; +use http::StatusCode; +use log::error; + +pub static POSTS_PER_PAGE: usize = 20; + +/// Return a pretty location specifier from a geo: URI. +fn decode_geo_uri(uri: &str) -> String { + if let Some(part) = uri.split(':').collect::>().get(1) { + if let Some(part) = part.split(';').next() { + let mut parts = part.split(','); + let lat = parts.next().unwrap(); + let lon = parts.next().unwrap(); + // TODO - format them as proper latitude and longitude + return format!("{}, {}", lat, lon); + } else { + uri.to_string() + } + } else { + uri.to_string() + } +} + +markup::define! { + Template<'a>(title: &'a str, blog_name: &'a str, endpoints: Option, feeds: Vec, user: Option, content: String) { + @markup::doctype() + html { + head { + title { @title } + link[rel="preconnect", href="https://fonts.gstatic.com"]; + link[rel="stylesheet", href="/static/style.css"]; + meta[name="viewport", content="initial-scale=1, width=device-width"]; + // TODO: link rel= for common IndieWeb APIs: webmention, microsub + link[rel="micropub", href="/micropub"]; // Static, because it's built into the server itself + @if let Some(endpoints) = endpoints { + link[rel="authorization_endpoint", href=&endpoints.authorization_endpoint]; + link[rel="token_endpoint", href=&endpoints.token_endpoint]; + @if let Some(webmention) = &endpoints.webmention { + link[rel="webmention", href=&webmention]; + } + @if let Some(microsub) = &endpoints.microsub { + link[rel="microsub", href=µsub]; + } + } + } + body { + // TODO Somehow compress headerbar into a menu when the screen space is tight + nav #headerbar { + ul { + li { a #homepage[href="/"] { @blog_name } } + @for feed in feeds.iter() { + li { a[href=&feed.uid] { @feed.name } } + } + li.shiftright { + @if user.is_none() { + a #login[href="/login"] { "Sign in" } + } else { + span { + @user.as_ref().unwrap() " - " a #logout[href="/logout"] { "Sign out" } + } + } + } + } + } + main { + @markup::raw(content) + } + } + } + } + Entry<'a>(post: &'a serde_json::Value) { + article."h-entry" { + header.metadata { + @if post["properties"]["name"][0].is_string() { + h1."p-name" { + @post["properties"]["name"][0].as_str().unwrap() + } + } else { + @if post["properties"]["author"][0].is_object() { + section."mini-h-card" { + a.larger[href=post["properties"]["author"][0]["properties"]["uid"][0].as_str().unwrap()] { + @if post["properties"]["author"][0]["properties"]["photo"][0].is_string() { + img[src=post["properties"]["author"][0]["properties"]["photo"][0].as_str().unwrap()] {} + } + @post["properties"]["author"][0]["properties"]["name"][0].as_str().unwrap() + } + } + } + } + div { + span { + a."u-url"."u-uid"[href=post["properties"]["uid"][0].as_str().unwrap()] { + time."dt-published"[datetime=post["properties"]["published"][0].as_str().unwrap()] { + @chrono::DateTime::parse_from_rfc3339(post["properties"]["published"][0].as_str().unwrap()) + .map(|dt| dt.format("%a %b %e %T %Y").to_string()) + .unwrap_or("ERROR: Couldn't parse the datetime".to_string()) + } + } + } + @if post["properties"]["visibility"][0].as_str().unwrap_or("public") != "public" { + span."p-visibility"[value=post["properties"]["visibility"][0].as_str().unwrap()] { + @post["properties"]["visibility"][0].as_str().unwrap() + } + } + @if post["properties"]["category"].is_array() { + span { + ul.categories { + "Tagged: " + @for cat in post["properties"]["category"].as_array().unwrap() { + li."p-category" { @cat.as_str().unwrap() } + } + } + } + } + @if post["properties"]["in-reply-to"].is_array() { + // TODO: Rich reply contexts - blocked on MF2 parser + span { + "In reply to: " + ul.replyctx { + @for ctx in post["properties"]["in-reply-to"].as_array().unwrap() { + li { a."u-in-reply-to"[href=ctx.as_str().unwrap()] { + @ctx.as_str().unwrap().truncate_ellipse(24).as_ref() + } } + } + } + } + } + } + @if post["properties"]["url"].as_array().unwrap().len() > 1 { + hr; + ul { + "Pretty permalinks for this post:" + @for url in post["properties"]["url"].as_array().unwrap().iter().filter(|i| **i != post["properties"]["uid"][0]).map(|i| i.as_str().unwrap()) { + li { + a."u-url"[href=url] { @url } + } + } + } + } + @if post["properties"]["location"].is_array() || post["properties"]["checkin"].is_array() { + div { + @if post["properties"]["checkin"].is_array() { + span { + "Check-in to: " + @if post["properties"]["checkin"][0].is_string() { + // It's a URL + a."u-checkin"[href=post["properties"]["checkin"][0].as_str().unwrap()] { + @post["properties"]["checkin"][0].as_str().unwrap().truncate_ellipse(24).as_ref() + } + } else { + a."u-checkin"[href=post["properties"]["checkin"][0]["properties"]["uid"][0].as_str().unwrap()] { + @post["properties"]["checkin"][0]["properties"]["name"][0].as_str().unwrap() + } + } + } + } + @if post["properties"]["location"].is_array() { + span { + "Location: " + @if post["properties"]["location"][0].is_string() { + // It's a geo: URL + // We need to decode it + a."u-location"[href=post["properties"]["location"][0].as_str().unwrap()] { + @decode_geo_uri(post["properties"]["location"][0].as_str().unwrap()) + } + } else { + // It's an inner h-geo object + a."u-location"[href=post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap()))] { + // I'm a lazy bitch + @decode_geo_uri(&post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap()))) + } + } + } + } + } + } + @if post["properties"]["ate"].is_array() || post["properties"]["drank"].is_array() { + div { + @if post["properties"]["ate"].is_array() { + span { ul { + "Ate:" + @for food in post["properties"]["ate"].as_array().unwrap() { + li { + @if food.is_string() { + // If this is a string, it's a URL. + a."u-ate"[href=food.as_str().unwrap()] { + @food.as_str().unwrap().truncate_ellipse(24).as_ref() + } + } else { + // This is a rich food object (mm, sounds tasty! I wanna eat something tasty) + a."u-ate"[href=food["properties"]["uid"][0].as_str().unwrap_or("#")] { + @food["properties"]["name"][0].as_str() + .unwrap_or(food["properties"]["uid"][0].as_str().unwrap_or("#").truncate_ellipse(24).as_ref()) + } + } + } + } + } } + } + @if post["properties"]["drank"].is_array() { + span { ul { + "Drank:" + @for food in post["properties"]["drank"].as_array().unwrap() { + li { + @if food.is_string() { + // If this is a string, it's a URL. + a."u-drank"[href=food.as_str().unwrap()] { + @food.as_str().unwrap().truncate_ellipse(24).as_ref() + } + } else { + // This is a rich food object (mm, sounds tasty! I wanna eat something tasty) + a."u-drank"[href=food["properties"]["uid"][0].as_str().unwrap_or("#")] { + @food["properties"]["name"][0].as_str() + .unwrap_or(food["properties"]["uid"][0].as_str().unwrap_or("#").truncate_ellipse(24).as_ref()) + } + } + } + } + } } + } + } + } + } + @PhotoGallery { photos: post["properties"]["photo"].as_array() } + @if post["properties"]["content"][0]["html"].is_string() { + main."e-content" { + @markup::raw(post["properties"]["content"][0]["html"].as_str().unwrap().trim()) + } + } + @WebInteractions { post } + } + } + PhotoGallery<'a>(photos: Option<&'a Vec>) { + @if photos.is_some() { + @for photo in photos.unwrap() { + @if photo.is_string() { + img."u-photo"[src=photo.as_str().unwrap(), loading="lazy"]; + } else if photo.is_array() { + @if photo["thumbnail"].is_string() { + a."u-photo"[href=photo["value"].as_str().unwrap()] { + img[src=photo["thumbnail"].as_str().unwrap(), loading="lazy", alt=photo["alt"].as_str().unwrap_or("")]; + } + } else { + img."u-photo"[src=photo["value"].as_str().unwrap(), loading="lazy", alt=photo["alt"].as_str().unwrap_or("")]; + } + } + } + } + } + WebInteractions<'a>(post: &'a serde_json::Value) { + footer.webinteractions { + ul.counters { + li { + span.icon { "❤️" } + span.counter { @post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0) } + } + li { + span.icon { "💬" } + span.counter { @post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0) } + } + li { + span.icon { "🔄" } + span.counter { @post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0) } + } + li { + span.icon { "🔖" } + span.counter { @post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0) } + } + } + // Needs rich webmention support which may or may not depend on an MF2 parser + // Might circumvent with an external parser with CORS support + // why write this stuff in rust then tho + /*details { + summary { "Show comments and reactions" } + @if post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0) > 0 { + // Show a facepile of likes for a post + } + @if post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0) > 0 { + // Show a facepile of bookmarks for a post + } + @if post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0) > 0 { + // Show a facepile of reposts for a post + } + @if post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0) > 0 { + // Show all the comments recursively (so we could do Salmention with them) + } + }*/ + } + } + VCard<'a>(card: &'a serde_json::Value) { + article."h-card" { + @if card["properties"]["photo"][0].is_string() { + img."u-photo"[src=card["properties"]["photo"][0].as_str().unwrap()]; + } + h1 { + a."u-url"."u-uid"."p-name"[href=card["properties"]["uid"][0].as_str().unwrap()] { + @card["properties"]["name"][0].as_str().unwrap() + } + } + @if card["properties"]["pronoun"].is_array() { + span { + "(" + @for (i, pronoun) in card["properties"]["pronoun"].as_array().unwrap().iter().filter_map(|v| v.as_str()).enumerate() { + span."p-pronoun" { + @pronoun + } + // Insert commas between multiple sets of pronouns + @if i < (card["properties"]["pronoun"].as_array().unwrap().len() - 1) {", "} + } + ")" + } + } + @if card["properties"]["note"].is_array() { + p."p-note" { + @card["properties"]["note"][0]["value"].as_str().unwrap_or_else(|| card["properties"]["note"][0].as_str().unwrap()) + } + } + @if card["properties"]["url"].is_array() { + ul { + "Can be found elsewhere at:" + @for url in card["properties"]["url"].as_array().unwrap().iter().filter_map(|v| v.as_str()).filter(|v| v != &card["properties"]["uid"][0].as_str().unwrap()).filter(|v| !v.starts_with(&card["properties"]["author"][0].as_str().unwrap())) { + li { a."u-url"[href=url, rel="me"] { @url } } + } + } + } + } + } + Food<'a>(food: &'a serde_json::Value) { + article."h-food" { + header.metadata { + h1 { + a."p-name"."u-url"[href=food["properties"]["url"][0].as_str().unwrap()] { + @food["properties"]["name"][0].as_str().unwrap() + } + } + } + @PhotoGallery { photos: food["properties"]["photo"].as_array() } + } + } + Feed<'a>(feed: &'a serde_json::Value) { + div."h-feed" { + div.metadata { + @if feed["properties"]["name"][0].is_string() { + h1."p-name".titanic { + a[href=feed["properties"]["uid"][0].as_str().unwrap(), rel="feed"] { + @feed["properties"]["name"][0].as_str().unwrap() + } + } + } + } + @if feed["children"].is_array() { + @for child in feed["children"].as_array().unwrap() { + @match child["type"][0].as_str().unwrap() { + "h-entry" => { @Entry { post: child } } + "h-feed" => { @Feed { feed: child } } + "h-event" => { + @{error!("Templating error: h-events aren't implemented yet");} + } + "h-card" => { @VCard { card: child }} + something_else => { + @{error!("Templating error: found a {} object that couldn't be parsed", something_else);} + } + } + } + } + @if feed["children"].as_array().map(|a| a.len()).unwrap_or(0) == 0 { + p { + "Looks like you reached the end. Wanna jump back to the " + a[href=feed["properties"]["uid"][0].as_str().unwrap()] { + "beginning" + } "?" + } + } + @if feed["children"].as_array().map(|a| a.len()).unwrap_or(0) == super::POSTS_PER_PAGE { + a[rel="prev", href=feed["properties"]["uid"][0].as_str().unwrap().to_string() + + "?after=" + feed["children"][super::POSTS_PER_PAGE - 1]["properties"]["uid"][0].as_str().unwrap()] { + "Older posts" + } + } + } + } + MainPage<'a>(feed: &'a serde_json::Value, card: &'a serde_json::Value) { + .sidebyside { + @VCard { card } + #dynamicstuff { + p { "This section will provide interesting statistics or tidbits about my life in this exact moment (with maybe a small delay)." } + p { "It will probably require JavaScript to self-update, but I promise to keep this widget lightweight and open-source!" } + p { small { + "JavaScript isn't a menace, stop fearing it or I will switch to WebAssembly " + "and knock your nico-nico-kneecaps so fast with its speed you won't even notice that... " + small { "omae ha mou shindeiru" } + @markup::raw("") + } } + } + } + @Feed { feed } + } + ErrorPage(code: StatusCode, msg: Option) { + h1 { @format!("HTTP {}", code) } + @match *code { + StatusCode::UNAUTHORIZED => { + p { "Looks like you need to authenticate yourself before seeing this page. Try logging in with IndieAuth using the Login button above!" } + } + StatusCode::FORBIDDEN => { + p { "Looks like you're forbidden from viewing this page." } + p { + "This might've been caused by being banned from viewing my website" + "or simply by trying to see what you're not supposed to see, " + "like a private post that's not intended for you. It's ok, it happens." + } + } + StatusCode::GONE => { + p { "Looks like the page you're trying to find is gone and is never coming back." } + } + StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS => { + p { "The page is there, but I can't legally provide it to you because the censorship said so." } + } + StatusCode::NOT_FOUND => { + p { "Looks like there's no such page. Maybe you or someone else mistyped a URL or my database experienced data loss." } + } + StatusCode::IM_A_TEAPOT => { + p { "Wait, do you seriously expect my website to brew you coffee? It's not a coffee machine!" } + + p { + small { + "I could brew you some coffee tho if we meet one day... " + small { + i { + "i-it's nothing personal, I just like brewing coffee, b-baka!!!~ >. { + @match msg { + None => { + p { + "There was an undescribed error in your request. " + "Please try again later or with a different request." + } + } + Some(msg) => { + p { + "There was a following error in your request:" + } + blockquote { pre { @msg } } + } + } + } + StatusCode::INTERNAL_SERVER_ERROR => { + @match msg { + None => { + p { "It seems like you have found an error. Not to worry, it has already been logged." } + } + Some(msg) => { + p { "The server encountered an error while processing your request:" } + blockquote { @msg } + p { "Don't worry, it has already been logged." } + } + } + } + _ => { + p { "It seems like you have found an error. Not to worry, it has already been logged." } + } + } + P { "For now, may I suggest to visit " a[href="/"] {"the main page"} " of this website?" } + + } +} diff --git a/util/Cargo.toml b/util/Cargo.toml new file mode 100644 index 0000000..31c6bca --- /dev/null +++ b/util/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "kittybox-util" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +[dependencies.serde] # A generic serialization/deserialization framework +version = "^1.0.125" +features = ["derive"] + diff --git a/util/src/lib.rs b/util/src/lib.rs new file mode 100644 index 0000000..bc41689 --- /dev/null +++ b/util/src/lib.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct IndiewebEndpoints { + pub authorization_endpoint: String, + pub token_endpoint: String, + pub webmention: Option, + pub microsub: Option, +} + +/// Data structure representing a Micropub channel in the ?q=channels output. +#[derive(Serialize, Deserialize, PartialEq, Debug)] +pub struct MicropubChannel { + /// The channel's UID. It is usually also a publically accessible permalink URL. + pub uid: String, + /// The channel's user-friendly name used to recognize it in lists. + pub name: String, +} -- cgit 1.4.1