about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2021-05-17 09:38:40 +0300
committerVika <vika@fireburn.ru>2021-05-17 09:38:40 +0300
commit0fbe373fcb716fbc6eb344022ab72de00512fc68 (patch)
treece1529b27973599f84cb68d3e14c01ba956e28d7 /src
parent9d90a72131153d53d751505aab883047cd0645c7 (diff)
downloadkittybox-0fbe373fcb716fbc6eb344022ab72de00512fc68.tar.zst
Onboarding - initial feature
Diffstat (limited to 'src')
-rw-r--r--src/frontend/mod.rs274
-rw-r--r--src/frontend/onboarding.css33
-rw-r--r--src/frontend/onboarding.js45
-rw-r--r--src/lib.rs3
4 files changed, 349 insertions, 6 deletions
diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs
index 891e944..f14ac75 100644
--- a/src/frontend/mod.rs
+++ b/src/frontend/mod.rs
@@ -31,7 +31,7 @@ mod templates {
     }
 
     markup::define! {
-        Template<'a>(title: &'a str, endpoints: IndiewebEndpoints, content: String) {
+        Template<'a>(title: &'a str, blog_name: &'a str, endpoints: IndiewebEndpoints, content: String) {
             @markup::doctype()
             html {
                 head {
@@ -49,7 +49,7 @@ mod templates {
                         // 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 { a#homepage[href="/"] { @blog_name } }
                             li.shiftright { a#login[href="/login"] { "Login" } }
                         }
                     }
@@ -59,6 +59,180 @@ mod templates {
                 }
             }
         }
+        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 MIT (X11) or Apache-2.0 license - your choice) "
+                        "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("<!-- do cats even have souls? I'm not sure. But this code won't steal their souls anyway. -->")
+                    }
+                    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="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("&mdash;")
+                        " though you're free to write anything you want. (By the way, you can use 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" }
+                    }
+                }
+            }
+        }
         Entry<'a>(post: &'a serde_json::Value) {
             article."h-entry" {
                 header.metadata {
@@ -382,7 +556,7 @@ mod templates {
     }
 }
 
-use templates::{Template,ErrorPage,MainPage};
+use templates::{Template,ErrorPage,MainPage,OnboardingPage};
 
 #[derive(Clone, Serialize, Deserialize)]
 pub struct IndiewebEndpoints {
@@ -450,6 +624,74 @@ async fn get_post_from_database<S: Storage>(db: &S, url: &str, after: Option<Str
     }
 }
 
+#[derive(Deserialize)]
+struct OnboardingFeed {
+    slug: String,
+    name: String
+}
+
+#[derive(Deserialize)]
+struct OnboardingData {
+    user: serde_json::Value,
+    first_post: serde_json::Value,
+    blog_name: String,
+    feeds: Vec<OnboardingFeed>
+}
+
+pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
+    use serde_json::json;
+
+    let body = req.body_json::<OnboardingData>().await?;
+    let backend = &req.state().storage;
+    #[cfg(any(not(debug_assertions), test))]
+    let me = req.url();
+    #[cfg(all(debug_assertions, not(test)))]
+    let me = url::Url::parse("http://localhost:8080/").unwrap();
+
+    if let Ok(_) = get_post_from_database(backend, me.as_str(), None, &None).await {
+        Err(FrontendError::with_code(StatusCode::Forbidden, "Onboarding is over. Are you trying to take over somebody's website?!"))?
+    }
+    info!("Onboarding new user: {}", me);
+
+    let user = crate::indieauth::User::new(me.as_str(), "https://kittybox.fireburn.ru/", "create");
+
+    backend.set_setting("site_name", user.me.as_str(), &body.blog_name).await?;
+
+    if body.user["type"][0] != "h-card" || body.first_post["type"][0] != "h-entry" {
+        Err(FrontendError::with_code(StatusCode::BadRequest, "user and first_post should be h-card and h-entry"))?
+    }
+    info!("Validated body.user and body.first_post as microformats2");
+
+    let mut hcard = body.user;
+    let hentry = body.first_post;
+
+    // Ensure the h-card's UID is set to the main page, so it will be fetchable.
+    hcard["properties"]["uid"] = json!([ me.as_str() ]);
+    // Normalize the h-card - note that it should preserve the UID we set here.
+    let (_, hcard) = crate::micropub::normalize_mf2(hcard, &user);
+    // The h-card is written directly - all the stuff in the Micropub's
+    // post function is just to ensure that the posts will be syndicated
+    // and inserted into proper feeds. Here, we don't have a need for this,
+    // since the h-card is DIRECTLY accessible via its own URL.
+    backend.put_post(&hcard).await?;
+
+    for feed in body.feeds {
+        let (_, feed) = crate::micropub::normalize_mf2(json!({
+            "type": ["h-feed"],
+            "properties": {"name": [feed.name], "mp-slug": [feed.slug]}
+        }), &user);
+
+        backend.put_post(&feed).await?;
+    }
+
+    // This basically puts the h-entry post through the normal creation process.
+    // We need to insert it into feeds and optionally send a notification to everywhere.
+    req.set_ext(user);
+    crate::micropub::post::new_post(req, hentry).await?;
+
+    return Ok(Response::builder(201).header("Location", "/").build());
+}
+
 pub async fn coffee<S: Storage>(_: Request<ApplicationState<S>>) -> Result {
     Err(FrontendError::with_code(StatusCode::ImATeapot, "Someone asked this website to brew them some coffee..."))?;
     return Ok(Response::builder(500).build()) // unreachable
@@ -481,7 +723,15 @@ pub async fn mainpage<S: Storage>(req: Request<ApplicationState<S>>) -> Result {
         let feed_err = feed.unwrap_err();
         if card_err.code == 404 {
             // Yes, we definitely need some onboarding here.
-            todo!()
+            Ok(Response::builder(200).content_type("text/html; charset=utf-8").body(Template { 
+                title: "Kittybox - Onboarding",
+                blog_name: "Kitty Box!",
+                endpoints: IndiewebEndpoints {
+                  authorization_endpoint, token_endpoint,
+                  webmention: None, microsub: None
+                },
+                content: OnboardingPage {}.to_string()
+            }.to_string()).build())
         } else {
             Err(feed_err)?
         }
@@ -490,6 +740,7 @@ pub async fn mainpage<S: Storage>(req: Request<ApplicationState<S>>) -> Result {
             .content_type("text/html; charset=utf-8")
             .body(Template {
                 title: &format!("{} - Main page", url.host().unwrap().to_string()),
+                blog_name: &backend.get_setting("site_name", &url.host().unwrap().to_string()).await.unwrap_or_else(|_| "Kitty Box!".to_string()),
                 endpoints: IndiewebEndpoints {
                   authorization_endpoint, token_endpoint,
                   webmention: None, microsub: None
@@ -527,6 +778,7 @@ pub async fn render_post<S: Storage>(req: Request<ApplicationState<S>>) -> Resul
         .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())),
+            blog_name: &req.state().storage.get_setting("site_name", &url.host().unwrap().to_string()).await.unwrap_or_else(|_| "Kitty Box!".to_string()),
             endpoints: IndiewebEndpoints {
                 authorization_endpoint, token_endpoint,
                 webmention: None, microsub: None
@@ -545,6 +797,7 @@ impl<S> tide::Middleware<ApplicationState<S>> for ErrorHandlerMiddleware where
     async fn handle(&self, request: Request<ApplicationState<S>>, next: Next<'_, ApplicationState<S>>) -> Result {
         let authorization_endpoint = request.state().authorization_endpoint.to_string();
         let token_endpoint = request.state().token_endpoint.to_string();
+        let site_name = &request.state().storage.get_setting("site_name", &request.url().host().unwrap().to_string()).await.unwrap_or_else(|_| "Kitty Box!".to_string());
         let mut res = next.run(request).await;
         let mut code: Option<StatusCode> = None;
         if let Some(err) = res.downcast_error::<FrontendError>() {
@@ -561,6 +814,7 @@ impl<S> tide::Middleware<ApplicationState<S>> for ErrorHandlerMiddleware where
             res.set_content_type("text/html; charset=utf-8");
             res.set_body(Template {
                 title: "Error",
+                blog_name: site_name,
                 endpoints: IndiewebEndpoints {
                     authorization_endpoint, token_endpoint,
                     webmention: None, microsub: None
@@ -573,6 +827,8 @@ impl<S> tide::Middleware<ApplicationState<S>> for ErrorHandlerMiddleware where
 }
 
 static STYLE_CSS: &[u8] = include_bytes!("./style.css");
+static ONBOARDING_JS: &[u8] = include_bytes!("./onboarding.js");
+static ONBOARDING_CSS: &[u8] = include_bytes!("./onboarding.css");
 
 pub async fn handle_static<S: Storage>(req: Request<ApplicationState<S>>) -> Result {
     Ok(match req.param("path") {
@@ -580,7 +836,15 @@ pub async fn handle_static<S: Storage>(req: Request<ApplicationState<S>>) -> Res
             .content_type("text/css; charset=utf-8")
             .body(STYLE_CSS)
             .build()),
+        Ok("onboarding.js") => Ok(Response::builder(200)
+            .content_type("text/javascript; charset=utf-8")
+            .body(ONBOARDING_JS)
+            .build()),
+        Ok("onboarding.css") => Ok(Response::builder(200)
+            .content_type("text/css; charset=utf-8")
+            .body(ONBOARDING_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/onboarding.css b/src/frontend/onboarding.css
new file mode 100644
index 0000000..6f191b9
--- /dev/null
+++ b/src/frontend/onboarding.css
@@ -0,0 +1,33 @@
+form.onboarding > ul#progressbar > li.active {
+    font-weight: bold;
+}
+form.onboarding > ul#progressbar {
+  display: flex; list-style: none; justify-content: space-around;
+}
+
+form.onboarding > fieldset > div.switch_card_buttons {
+    display: flex;
+    justify-content: space-between;
+    width: 100%;
+}
+form.onboarding > fieldset > div.switch_card_buttons button:last-child {
+    margin-left: auto;
+}
+.form_group, .multi_input {
+    display: flex;
+    flex-direction: column;
+}
+.multi_input {
+    align-items: start;
+}
+.multi_input > input {
+    width: 100%;
+    align-self: stretch;
+}
+form.onboarding > fieldset > .form_group + * {
+    margin-top: .75rem;
+}
+form.onboarding textarea {
+    width: 100%;
+    resize: vertical;
+}
diff --git a/src/frontend/onboarding.js b/src/frontend/onboarding.js
new file mode 100644
index 0000000..5bb08a1
--- /dev/null
+++ b/src/frontend/onboarding.js
@@ -0,0 +1,45 @@
+const firstOnboardingCard = "intro";
+
+function switchOnboardingCard(card) {
+    Array.from(document.querySelectorAll("form.onboarding > fieldset")).map(node => {
+        if (node.id == card) {
+            node.style.display = "block";
+        } else {
+            node.style.display = "none";
+        }
+    });
+
+    Array.from(document.querySelectorAll("form.onboarding > ul#progressbar > li")).map(node => {
+        if (node.id == card) {
+            node.classList.add("active")
+        } else {
+            node.classList.remove("active")
+        }
+    })
+};
+
+window.kittybox_onboarding = {
+    switchOnboardingCard
+};
+
+document.querySelector("form.onboarding > ul#progressbar").style.display = "";
+switchOnboardingCard(firstOnboardingCard);
+
+function switchCardOnClick(event) {
+    switchOnboardingCard(event.target.dataset.card)
+}
+
+function multiInputAddMore(event) {
+    let parent = event.target.parentElement;
+    let template = event.target.parentElement.querySelector("template").content.cloneNode(true);
+    parent.prepend(template);
+}
+
+Array.from(document.querySelectorAll("form.onboarding > fieldset button.switch_card")).map(button => {
+    button.addEventListener("click", switchCardOnClick)
+})
+
+Array.from(document.querySelectorAll("form.onboarding > fieldset div.multi_input > button.add_more")).map(button => {
+    button.addEventListener("click", multiInputAddMore)
+    multiInputAddMore({ target: button });
+})
\ No newline at end of file
diff --git a/src/lib.rs b/src/lib.rs
index 954f1f2..27adc1a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -35,7 +35,8 @@ where
         Ok(Response::builder(200).body(MICROPUB_CLIENT).content_type("text/html").build())
     });
     app.at("/").with(frontend::ErrorHandlerMiddleware {})
-        .get(frontend::mainpage);
+        .get(frontend::mainpage)
+        .post(frontend::onboarding_receiver);
     app.at("/static/*path").with(frontend::ErrorHandlerMiddleware {}).get(frontend::handle_static);
     app.at("/*path").with(frontend::ErrorHandlerMiddleware {}).get(frontend::render_post);
     app.at("/coffee").with(frontend::ErrorHandlerMiddleware {}).get(frontend::coffee);