diff options
author | Vika <vika@fireburn.ru> | 2021-05-09 16:51:24 +0300 |
---|---|---|
committer | Vika <vika@fireburn.ru> | 2021-05-09 16:51:24 +0300 |
commit | 22f5a47425dc677294b6ae9ebf7ffe949e9dc903 (patch) | |
tree | 9c3b169c413dde25ceacd9d68bdc4f31e08fd6b3 /src/frontend | |
parent | af8d55312ef99357ab23608eef6bf7fa40635828 (diff) | |
download | kittybox-22f5a47425dc677294b6ae9ebf7ffe949e9dc903.tar.zst |
Added a frontend to the application. TODO: Login, alternative themes, built-in Micropub capabilities when logged in
Diffstat (limited to 'src/frontend')
-rw-r--r-- | src/frontend/mod.rs | 547 | ||||
-rw-r--r-- | src/frontend/style.css | 159 |
2 files changed, 706 insertions, 0 deletions
diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs new file mode 100644 index 0000000..aaaa2b2 --- /dev/null +++ b/src/frontend/mod.rs @@ -0,0 +1,547 @@ +use serde::Deserialize; +use tide::{Request, Response, Result, StatusCode, Next}; +use log::{info,error}; +use crate::ApplicationState; +use crate::database::Storage; + +static POSTS_PER_PAGE: usize = 20; + +mod templates { + use log::error; + use http_types::StatusCode; + use ellipse::Ellipse; + use chrono; + + /// Return a pretty location specifier from a geo: URI. + fn decode_geo_uri(uri: &str) -> String { + if let Some(part) = uri.split(":").collect::<Vec<_>>().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 { + return uri.to_string() + } + } else { + return uri.to_string() + } + } + + markup::define! { + Template<'a>(title: &'a str, 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"]; + } + body { + nav#headerbar { + // TODO: Find a way to store the website name somewhere in the database + // Maybe in the settings? + ul { + li { a#homepage[href="/"] { "Vika's Hideout" } } + li.shiftright { a#login[href="/login"] { "Login" } } + } + } + 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() + } + } + 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"]["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"]["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()] { + @food["properties"]["name"][0].as_str() + .unwrap_or(food["properties"]["uid"][0].as_str().unwrap().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()] { + @food["properties"]["name"][0].as_str() + .unwrap_or(food["properties"]["uid"][0].as_str().unwrap().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: post } + } + } + PhotoGallery<'a>(photos: Option<&'a Vec<serde_json::Value>>) { + @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() { + 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) {", "} + } + ")" + } + } + p."p-note" { + @card["properties"]["note"][0]["value"].as_str().unwrap_or_else(|| card["properties"]["note"][0].as_str().unwrap()) + } + } + } + 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" } + // NANI?!!! + } } + } + } + @Feed { feed } + } + ErrorPage(code: StatusCode) { + h1 { @format!("HTTP {} {}", code, code.canonical_reason()) } + @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::UnavailableForLegalReasons => { + p { "The page is there, but I can't legally provide it to you because the censorship said so." } + } + StatusCode::NotFound => { + p { "Looks like there's no such page. Maybe you or someone else mistyped a URL or my database experienced data loss." } + } + StatusCode::ImATeapot => { + 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!!!~ >.<!" } } } + } + } + _ => { 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?" } + + } + } +} + +use templates::{Template,ErrorPage,MainPage}; + +#[derive(Deserialize)] +struct QueryParams { + after: Option<String> +} + +#[derive(Debug)] +struct FrontendError { + msg: String, + source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>, + code: StatusCode +} +impl FrontendError { + pub fn with_code(code: StatusCode, msg: &str) -> Self { + Self { msg: msg.to_string(), source: None, code } + } + pub fn msg(&self) -> &str { &self.msg } + pub fn code(&self) -> StatusCode { self.code } +} +impl From<crate::database::StorageError> for FrontendError { + fn from(err: crate::database::StorageError) -> Self { + Self { + msg: "Database error".to_string(), + source: Some(Box::new(err)), + code: StatusCode::InternalServerError + } + } +} +impl std::error::Error for FrontendError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source.as_ref().map(|e| e.as_ref() as &(dyn std::error::Error + 'static)) + } +} +impl std::fmt::Display for FrontendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.msg) + } +} + +async fn get_post_from_database<S: Storage>(db: &S, url: &str, after: Option<String>, user: &Option<String>) -> std::result::Result<serde_json::Value, FrontendError> { + match db.read_feed_with_limit(url, &after, POSTS_PER_PAGE, user).await { + Ok(result) => match result { + Some(post) => Ok(post), + None => Err(FrontendError::with_code(StatusCode::NotFound, "Post not found in the database")) + }, + Err(err) => match err.kind() { + crate::database::ErrorKind::PermissionDenied => { + // TODO: Authentication + if user.is_some() { + Err(FrontendError::with_code(StatusCode::Forbidden, "User authenticated AND forbidden to access this resource")) + } else { + Err(FrontendError::with_code(StatusCode::Unauthorized, "User needs to authenticate themselves")) + } + } + _ => Err(err.into()) + } + } +} + +pub async fn mainpage<S: Storage>(req: Request<ApplicationState<S>>) -> Result { + let backend = &req.state().storage; + let query = req.query::<QueryParams>()?; + let user: Option<String> = None; + + #[cfg(any(not(debug_assertions), test))] + let url = req.url(); + #[cfg(all(debug_assertions, not(test)))] + let url = url::Url::parse("http://localhost:8080/").unwrap(); + + info!("Request at {}", url); + let hcard_url = url.as_str(); + let feed_url = url.join("feeds/main").unwrap().to_string(); + + let card = get_post_from_database(backend, hcard_url, None, &user).await; + let feed = get_post_from_database(backend, &feed_url, query.after, &user).await; + + if card.is_err() && feed.is_err() { + // Uh-oh! No main feed and no h-card? Need to do onboarding. + // We can do it from inside the app without ever requesting an auth token. + let card_err = card.unwrap_err(); + let feed_err = feed.unwrap_err(); + if card_err.code == 404 { + // Yes, we definitely need some onboarding here. + todo!() + } else { + Err(feed_err)? + } + } else { + Ok(Response::builder(200) + .content_type("text/html; charset=utf-8") + .body(Template { + title: &format!("{} - Main page", url.host().unwrap().to_string()), + content: MainPage { + feed: &feed?, + card: &card? + }.to_string() + }.to_string() + ).build()) + } +} + +pub async fn render_post<S: Storage>(req: Request<ApplicationState<S>>) -> Result { + let query = req.query::<QueryParams>()?; + let user: Option<String> = None; + + #[cfg(any(not(debug_assertions), test))] + let url = req.url(); + #[cfg(all(debug_assertions, not(test)))] + let url = url::Url::parse("http://localhost:8080/").unwrap().join(req.url().path()).unwrap(); + + let post = get_post_from_database(&req.state().storage, url.as_str(), query.after, &user).await?; + + let template: String = match post["type"][0].as_str().expect("Empty type array or invalid type") { + "h-entry" => templates::Entry { post: &post }.to_string(), + "h-card" => templates::VCard { card: &post }.to_string(), + "h-feed" => templates::Feed { feed: &post }.to_string(), + _ => Err(FrontendError::with_code(StatusCode::InternalServerError, "Couldn't render an unknown type"))? + }; + + Ok(Response::builder(200) + .content_type("text/html; charset=utf-8") + .body(Template { + title: post["properties"]["name"][0].as_str().unwrap_or(&format!("Note at {}", url.host().unwrap().to_string())), + content: template + }.to_string() + ).build()) +} + +pub struct ErrorHandlerMiddleware {} + +#[async_trait::async_trait] +impl<S> tide::Middleware<ApplicationState<S>> for ErrorHandlerMiddleware where + S: crate::database::Storage +{ + async fn handle(&self, request: Request<ApplicationState<S>>, next: Next<'_, ApplicationState<S>>) -> Result { + let mut res = next.run(request).await; + let mut code: Option<StatusCode> = None; + if let Some(err) = res.downcast_error::<FrontendError>() { + code = Some(err.code()); + error!("Error caught while processing request: {}", err.msg()); + let mut err: &dyn std::error::Error = err; + while let Some(e) = err.source() { + error!("Caused by: {}", e); + err = e; + } + } + if let Some(code) = code { + res.set_status(code); + res.set_content_type("text/html; charset=utf-8"); + res.set_body(Template { title: "Error", content: ErrorPage { code }.to_string()}.to_string()); + } + Ok(res) + } +} + +static STYLE_CSS: &[u8] = include_bytes!("./style.css"); + +pub async fn handle_static<S: Storage>(req: Request<ApplicationState<S>>) -> Result { + Ok(match req.param("path") { + Ok("style.css") => Ok(Response::builder(200) + .content_type("text/css; charset=utf-8") + .body(STYLE_CSS) + .build()), + Ok(_) => Err(FrontendError::with_code(StatusCode::NotFound, "Static file not found")), + Err(_) => panic!("Invalid usage of the frontend::handle_static() function") + }?) +} \ No newline at end of file diff --git a/src/frontend/style.css b/src/frontend/style.css new file mode 100644 index 0000000..2c43808 --- /dev/null +++ b/src/frontend/style.css @@ -0,0 +1,159 @@ +@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@500&family=Lato&display=swap'); + +:root { + font-family: var(--font-normal); + --font-normal: 'Lato', sans-serif; + --font-accent: 'Caveat', cursive; + --type-scale: 1.250; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-accent); +} +.titanic { + font-size: 3.815rem +} +h1, .xxxlarge { + margin-top: 0; + margin-bottom: 0; + font-size: 3.052rem; +} +h2 .xxlarge {font-size: 2.441rem;} +h3 .xlarge {font-size: 1.953rem;} +h4 .larger {font-size: 1.563rem;} +h5, .large {font-size: 1.25rem;} +h6, .normal {font-size: 1rem;} +small, .small { font-size: 0.8em; } + +nav#headerbar { + background: purple; + color: whitesmoke; + border-bottom: .75rem solid gold; + padding: .3rem; + vertical-align: center; + position: sticky; + top: 0; +} +nav#headerbar a#homepage { + font-weight: bolder; + font-family: var(--font-accent); + font-size: 2rem; +} +nav#headerbar > ul { + display: flex; + padding: inherit; + margin: inherit; + gap: .75em; +} +nav#headerbar > ul > li { + display: inline-flex; + flex-direction: column; + marker: none; + padding: inherit; + margin: inherit; + justify-content: center; +} +nav#headerbar > ul > li.shiftright { + margin-left: auto; +} +nav#headerbar a { + color: white; +} +body > main { + max-width: 60rem; + margin: auto; + padding: .75rem; +} +.sidebyside { + display: flex; + flex-wrap: wrap; + gap: .75rem; + margin-top: .75rem; + margin-bottom: .75rem; +} +.sidebyside > * { + width: 100%; + margin-top: 0; + margin-bottom: 0; + border: .125rem solid black; + border-radius: .75rem; + padding: .75rem; + margin-top: 0 !important; + margin-bottom: 0 !important; + flex-basis: 28rem; + flex-grow: 1; +} +article > * + * { + margin-top: .75rem; +} +article > header { + padding-bottom: .75rem; + border-bottom: 1px solid gray; +} +article > footer { + border-top: 1px solid gray; +} +article.h-entry, article.h-feed, article.h-card, article.h-event { + border: 2px solid black; + border-radius: .75rem; + padding: .75rem; + margin-top: .75rem; + margin-bottom: .75rem; +} +.webinteractions > ul.counters { + display: inline-flex; + padding: inherit; + margin: inherit; + gap: .75em; + flex-wrap: wrap; +} +.webinteractions > ul.counters > li > .icon { + font-size: 1.5em; +} +.webinteractions > ul.counters > li { + display: inline-flex; + align-items: center; + gap: .5em; +} +article.h-entry > header.metadata ul { + padding-left: unset; + flex-wrap: wrap; + margin: unset; + display: inline-flex; + list-style-type: none; + +} +article.h-entry img.u-photo { + max-width: 80%; + max-height: 90vh; + display: block; + margin: auto; +} +article.h-entry img.u-photo + * { + margin-top: .75rem; +} +article.h-entry > header.metadata span + span::before { + content: " | " +} +article > header.metadata ul li { + display: inline; +} +li.p-category::before { + content: " #"; +} + +article.h-entry ul.categories { + gap: .2em; +} +article.h-card img.u-photo { + border-radius: 100%; + float: left; + height: 8rem; + border: 1px solid gray; + margin-right: .75em; +} \ No newline at end of file |