diff options
author | Vika <vika@fireburn.ru> | 2021-05-17 09:38:40 +0300 |
---|---|---|
committer | Vika <vika@fireburn.ru> | 2021-05-17 09:38:40 +0300 |
commit | 0fbe373fcb716fbc6eb344022ab72de00512fc68 (patch) | |
tree | ce1529b27973599f84cb68d3e14c01ba956e28d7 /src | |
parent | 9d90a72131153d53d751505aab883047cd0645c7 (diff) | |
download | kittybox-0fbe373fcb716fbc6eb344022ab72de00512fc68.tar.zst |
Onboarding - initial feature
Diffstat (limited to 'src')
-rw-r--r-- | src/frontend/mod.rs | 274 | ||||
-rw-r--r-- | src/frontend/onboarding.css | 33 | ||||
-rw-r--r-- | src/frontend/onboarding.js | 45 | ||||
-rw-r--r-- | src/lib.rs | 3 |
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("—") + " 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); |