about summary refs log tree commit diff
path: root/src/frontend
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2021-05-09 16:51:24 +0300
committerVika <vika@fireburn.ru>2021-05-09 16:51:24 +0300
commit22f5a47425dc677294b6ae9ebf7ffe949e9dc903 (patch)
tree9c3b169c413dde25ceacd9d68bdc4f31e08fd6b3 /src/frontend
parentaf8d55312ef99357ab23608eef6bf7fa40635828 (diff)
downloadkittybox-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.rs547
-rw-r--r--src/frontend/style.css159
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