about summary refs log tree commit diff
path: root/kittybox-rs/templates
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2022-10-07 19:53:04 +0300
committerVika <vika@fireburn.ru>2022-10-07 19:53:04 +0300
commit9f7b903901acb0cd6ec9cb2146406a92ebf79cab (patch)
treec7a45f69d2d59621365494dc2d3657848390b61d /kittybox-rs/templates
parent6cb479acc61ab19f655cedd878283b214e352a3d (diff)
downloadkittybox-9f7b903901acb0cd6ec9cb2146406a92ebf79cab.tar.zst
templates: move static assets to the templates crate
It makes more sense to keep CSS near the templates, and the
client-side JavaScript code too, since it depends on the DOM structure
to work. Additionally, the overhead of `include_dir!()` is almost
completely mitigated by the fact that this is a separate crate that
isn't recompiled often.

The linking stage, however, is still expected to take a little bit
long. But I doubt it'd be longer than what it was before, since it's
the same exact files that get linked into the app.
Diffstat (limited to 'kittybox-rs/templates')
-rw-r--r--kittybox-rs/templates/Cargo.toml12
-rw-r--r--kittybox-rs/templates/assets/jslicense.html31
-rw-r--r--kittybox-rs/templates/assets/onboarding.css33
-rw-r--r--kittybox-rs/templates/assets/style.css201
-rw-r--r--kittybox-rs/templates/build.rs26
-rw-r--r--kittybox-rs/templates/javascript/dist/indieauth.js118
-rw-r--r--kittybox-rs/templates/javascript/dist/webauthn/register.js1
-rw-r--r--kittybox-rs/templates/javascript/src/indieauth.ts150
-rw-r--r--kittybox-rs/templates/javascript/src/lib.ts3
-rw-r--r--kittybox-rs/templates/javascript/src/onboarding.ts120
-rw-r--r--kittybox-rs/templates/javascript/src/webauthn/register.ts0
-rw-r--r--kittybox-rs/templates/javascript/tsconfig.json104
-rw-r--r--kittybox-rs/templates/src/lib.rs31
13 files changed, 825 insertions, 5 deletions
diff --git a/kittybox-rs/templates/Cargo.toml b/kittybox-rs/templates/Cargo.toml
index a32a3a2..ffdfc25 100644
--- a/kittybox-rs/templates/Cargo.toml
+++ b/kittybox-rs/templates/Cargo.toml
@@ -1,5 +1,5 @@
 [package]
-name = "kittybox-templates"
+name = "kittybox-frontend-renderer"
 version = "0.1.0"
 edition = "2021"
 
@@ -12,10 +12,12 @@ rand = "^0.8.5"
 version="^0.2.0"
 
 [dependencies]
-ellipse = "^0.2.0"           # Truncate and ellipsize strings in a human-friendly way
-http = "^0.2.7"              # Hyper's strong HTTP types
-markup = "^0.12.0"           # HTML templating engine
-serde_json = "^1.0.64"       # A JSON serialization file format
+ellipse = "^0.2.0"
+http = "^0.2.7"
+markup = "^0.12.0"
+serde_json = "^1.0.64"
+include_dir = "^0.7.2"
+axum = "^0.5.16"
 [dependencies.chrono]
 version = "^0.4.19"
 features = ["serde"]
diff --git a/kittybox-rs/templates/assets/jslicense.html b/kittybox-rs/templates/assets/jslicense.html
new file mode 100644
index 0000000..90c681c
--- /dev/null
+++ b/kittybox-rs/templates/assets/jslicense.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>JavaScript licensing information for Kittybox</title>
+  </head>
+  <body>
+    <p>All JavaScript included with Kittybox is licensed as free software, most of it under AGPL-3.0.</p>
+    <table id="jslicense-labels1">
+      <tr>
+        <td><a href="/.kittybox/static/onboarding.js">onboarding.js</a></td>
+        <td><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a></td>
+        <td><a href="https://git.sr.ht/~vikanezrimaya/kittybox/tree/main/item/kittybox-rs/javascript/src/onboarding.ts">onboarding.ts (Kittybox source code)</a></td>
+      </tr>
+      <tr>
+        <td><a href="/.kittybox/static/indieauth.js">indieauth.js</a></td>
+        <td><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a></td>
+        <td><a href="https://git.sr.ht/~vikanezrimaya/kittybox/tree/main/item/kittybox-rs/javascript/src/indieauth.ts">indieauth.ts (Kittybox source code)</a></td>
+      </tr>
+      <tr>
+        <td><a href="/.kittybox/static/lib.js">lib.js</a></td>
+        <td><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a></td>
+        <td><a href="https://git.sr.ht/~vikanezrimaya/kittybox/tree/main/item/kittybox-rs/javascript/src/lib.ts">lib.ts (Kittybox source code)</a></td>
+      </tr>
+      <tr>
+        <td><a href="/.kittybox/static/indieauth.js">indieauth.js</a></td>
+        <td><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a></td>
+        <td><a href="https://git.sr.ht/~vikanezrimaya/kittybox/tree/main/item/kittybox-rs/javascript/src/indieauth.ts">indieauth.ts (Kittybox source code)</a></td>
+      </tr>
+    </table>
+  </body>
+</html>
diff --git a/kittybox-rs/templates/assets/onboarding.css b/kittybox-rs/templates/assets/onboarding.css
new file mode 100644
index 0000000..6f191b9
--- /dev/null
+++ b/kittybox-rs/templates/assets/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/kittybox-rs/templates/assets/style.css b/kittybox-rs/templates/assets/style.css
new file mode 100644
index 0000000..a8ef6e4
--- /dev/null
+++ b/kittybox-rs/templates/assets/style.css
@@ -0,0 +1,201 @@
+@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;
+
+    --primary-accent: purple;
+    --secondary-accent: gold;
+}
+* {
+    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: var(--primary-accent);
+    color: whitesmoke;
+    border-bottom: .75rem solid var(--secondary-accent);
+    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;
+}
+body > footer {
+    text-align: center;
+}
+.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-inline-start: unset;
+    margin: unset;
+}
+article.h-entry > header.metadata ul.categories {
+    flex-wrap: wrap;
+    display: inline-flex;
+    list-style-type: none;
+}
+article.h-entry > header.metadata ul.categories li {
+    display: inline;
+    margin-inline-start: unset;
+}
+article.h-entry > header.metadata ul li {
+    margin-inline-start: 2.5em;
+}
+article.h-entry .e-content pre {
+    border: 1px solid gray;
+    border-radius: 0.5em;
+    overflow-y: auto;
+    padding: 0.5em;
+}
+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: " | "
+}
+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;
+    object-fit: cover;
+    aspect-ratio: 1;
+}
+
+.mini-h-card img, #indieauth_page img {
+    height: 2em;
+    display: inline-block;
+    border: 2px solid gray;
+    border-radius: 100%;
+    margin-right: 0.5rem;
+}
+
+.mini-h-card * {
+    vertical-align: middle;
+}
+
+.mini-h-card a {
+    text-decoration: none;
+}
+
+#indieauth_page > #introduction {
+    border: .125rem solid gray;
+    border-radius: .75rem;
+    margin: 1.25rem;
+    padding: .75rem;
+}
diff --git a/kittybox-rs/templates/build.rs b/kittybox-rs/templates/build.rs
new file mode 100644
index 0000000..1140060
--- /dev/null
+++ b/kittybox-rs/templates/build.rs
@@ -0,0 +1,26 @@
+fn main() {
+    use std::env;
+    let out_dir = std::path::PathBuf::from(env::var("OUT_DIR").unwrap());
+    println!("cargo:rerun-if-changed=assets/");
+    let assets = std::fs::read_dir("assets").unwrap();
+    for file in assets.map(|a| a.unwrap()) {
+        std::fs::copy(
+            file.path(),
+            out_dir.join(file.file_name())
+        )
+            .unwrap();
+    }
+    println!("cargo::rerun-if-changed=javascript/");
+    if let Ok(exit) = std::process::Command::new("tsc")
+        .arg("--outDir")
+        .arg(&out_dir)
+        .current_dir("javascript")
+        .spawn()
+        .unwrap()
+        .wait()
+    {
+        if !exit.success() {
+            std::process::exit(exit.code().unwrap_or(1))
+        }
+    }
+}
diff --git a/kittybox-rs/templates/javascript/dist/indieauth.js b/kittybox-rs/templates/javascript/dist/indieauth.js
new file mode 100644
index 0000000..297b4b5
--- /dev/null
+++ b/kittybox-rs/templates/javascript/dist/indieauth.js
@@ -0,0 +1,118 @@
+"use strict";
+const WEBAUTHN_TIMEOUT = 60 * 1000;
+async function webauthn_create_credential() {
+    const response = await fetch("/.kittybox/webauthn/pre_register");
+    const { challenge, rp, user } = await response.json();
+    return await navigator.credentials.create({
+        publicKey: {
+            challenge: Uint8Array.from(challenge, (c) => c.charCodeAt(0)),
+            rp: rp,
+            user: {
+                id: Uint8Array.from(user.cred_id, (c) => c.charCodeAt(0)),
+                name: user.name,
+                displayName: user.displayName
+            },
+            pubKeyCredParams: [{ alg: -7, type: "public-key" }],
+            authenticatorSelection: {},
+            timeout: WEBAUTHN_TIMEOUT,
+            attestation: "none"
+        }
+    });
+}
+async function webauthn_authenticate() {
+    const response = await fetch("/.kittybox/webauthn/pre_auth");
+    const { challenge, credentials } = await response.json();
+    try {
+        return await navigator.credentials.get({
+            publicKey: {
+                challenge: Uint8Array.from(challenge, (c) => c.charCodeAt(0)),
+                allowCredentials: credentials.map(cred => ({
+                    id: Uint8Array.from(cred.id, c => c.charCodeAt(0)),
+                    type: cred.type
+                })),
+                timeout: WEBAUTHN_TIMEOUT
+            }
+        });
+    }
+    catch (e) {
+        console.error("WebAuthn authentication failed:", e);
+        alert("Using your authenticator failed. (Check the DevTools for details)");
+        throw e;
+    }
+}
+async function submit_handler(e) {
+    e.preventDefault();
+    if (e.target != null && e.target instanceof HTMLFormElement) {
+        const form = e.target;
+        let scopes;
+        if (form.elements.namedItem("scope") === undefined) {
+            scopes = [];
+        }
+        else if (form.elements.namedItem("scope") instanceof Node) {
+            scopes = [form.elements.namedItem("scope")]
+                .filter((e) => e.checked)
+                .map((e) => e.value);
+        }
+        else {
+            scopes = Array.from(form.elements.namedItem("scope"))
+                .filter((e) => e.checked)
+                .map((e) => e.value);
+        }
+        const authorization_request = {
+            response_type: form.elements.namedItem("response_type").value,
+            client_id: form.elements.namedItem("client_id").value,
+            redirect_uri: form.elements.namedItem("redirect_uri").value,
+            state: form.elements.namedItem("state").value,
+            code_challenge: form.elements.namedItem("code_challenge").value,
+            code_challenge_method: form.elements.namedItem("code_challenge_method").value,
+            // I would love to leave that as a list, but such is the form of
+            // IndieAuth.  application/x-www-form-urlencoded doesn't have
+            // lists, so scopes are space-separated instead. It is annoying.
+            scope: scopes.length > 0 ? scopes.join(" ") : undefined,
+        };
+        let credential = null;
+        switch (form.elements.namedItem("auth_method").value) {
+            case "password":
+                credential = form.elements.namedItem("user_password").value;
+                if (credential.length == 0) {
+                    alert("Please enter a password.");
+                    return;
+                }
+                break;
+            case "webauthn":
+                // credential = await webauthn_authenticate();
+                alert("WebAuthn isn't implemented yet!");
+                return;
+                break;
+            default:
+                alert("Please choose an authentication method.");
+                return;
+        }
+        console.log("Authorization request:", authorization_request);
+        console.log("Authentication method:", credential);
+        const body = JSON.stringify({
+            request: authorization_request,
+            authorization_method: credential
+        });
+        console.log(body);
+        const response = await fetch(form.action, {
+            method: form.method,
+            body: body,
+            headers: {
+                "Content-Type": "application/json"
+            }
+        });
+        if (response.ok) {
+            let location = response.headers.get("Location");
+            if (location != null) {
+                window.location.href = location;
+            }
+            else {
+                throw "Error: didn't return a location";
+            }
+        }
+    }
+    else {
+        return;
+    }
+}
diff --git a/kittybox-rs/templates/javascript/dist/webauthn/register.js b/kittybox-rs/templates/javascript/dist/webauthn/register.js
new file mode 100644
index 0000000..3918c74
--- /dev/null
+++ b/kittybox-rs/templates/javascript/dist/webauthn/register.js
@@ -0,0 +1 @@
+"use strict";
diff --git a/kittybox-rs/templates/javascript/src/indieauth.ts b/kittybox-rs/templates/javascript/src/indieauth.ts
new file mode 100644
index 0000000..01732b7
--- /dev/null
+++ b/kittybox-rs/templates/javascript/src/indieauth.ts
@@ -0,0 +1,150 @@
+import { unreachable } from "./lib.js";
+
+const WEBAUTHN_TIMEOUT = 60 * 1000;
+
+interface KittyboxWebauthnPreRegistrationData {
+  challenge: string,
+  rp: PublicKeyCredentialRpEntity,
+  user: {
+    cred_id: string,
+    name: string,
+    displayName: string
+  }
+}
+
+async function webauthn_create_credential() {
+  const response = await fetch("/.kittybox/webauthn/pre_register");
+  const { challenge, rp, user }: KittyboxWebauthnPreRegistrationData = await response.json();
+
+  return await navigator.credentials.create({
+    publicKey: {
+      challenge: Uint8Array.from(challenge, (c) => c.charCodeAt(0)),
+      rp: rp,
+      user: {
+        id: Uint8Array.from(user.cred_id, (c) => c.charCodeAt(0)),
+        name: user.name,
+        displayName: user.displayName
+      },
+      pubKeyCredParams: [{alg: -7, type: "public-key"}],
+      authenticatorSelection: {},
+      timeout: WEBAUTHN_TIMEOUT,
+      attestation: "none"
+    }
+  });
+}
+
+interface KittyboxWebauthnCredential {
+  id: string,
+  type: "public-key"
+}
+
+interface KittyboxWebauthnPreAuthenticationData {
+  challenge: string,
+  credentials: KittyboxWebauthnCredential[]
+}
+
+async function webauthn_authenticate() {
+  const response = await fetch("/.kittybox/webauthn/pre_auth");
+  const { challenge, credentials } = await response.json() as unknown as KittyboxWebauthnPreAuthenticationData;
+
+  try {
+    return await navigator.credentials.get({
+      publicKey: {
+        challenge: Uint8Array.from(challenge, (c) => c.charCodeAt(0)),
+        allowCredentials: credentials.map(cred => ({
+          id: Uint8Array.from(cred.id, c => c.charCodeAt(0)),
+          type: cred.type
+        })),
+        timeout: WEBAUTHN_TIMEOUT
+      }
+    })
+  } catch (e) {
+    console.error("WebAuthn authentication failed:", e);
+    alert("Using your authenticator failed. (Check the DevTools for details)");
+    throw e;
+  }
+}
+
+export async function submit_handler(e: SubmitEvent) {
+  e.preventDefault();
+  if (e.target != null && e.target instanceof HTMLFormElement) {
+    const form = e.target as HTMLFormElement;
+
+    let scopes: Array<string>;
+    let scope_elem = form.elements.namedItem("scope");
+    if (scope_elem == null) {
+      scopes = []
+    } else if (scope_elem instanceof Element) {
+      scopes = ([scope_elem] as Array<HTMLInputElement>)
+        .filter((e: HTMLInputElement) => e.checked)
+        .map((e: HTMLInputElement) => e.value);
+    } else if (scope_elem instanceof RadioNodeList) {
+      scopes = (Array.from(scope_elem) as Array<HTMLInputElement>)
+        .filter((e: HTMLInputElement) => e.checked)
+        .map((e: HTMLInputElement) => e.value);
+    } else {
+      unreachable("HTMLFormControlsCollection returned something that's not null, Element or RadioNodeList")
+    }
+
+    const authorization_request = {
+      response_type: (form.elements.namedItem("response_type") as HTMLInputElement).value,
+      client_id: (form.elements.namedItem("client_id") as HTMLInputElement).value,
+      redirect_uri: (form.elements.namedItem("redirect_uri") as HTMLInputElement).value,
+      state: (form.elements.namedItem("state") as HTMLInputElement).value,
+      code_challenge: (form.elements.namedItem("code_challenge") as HTMLInputElement).value,
+      code_challenge_method: (form.elements.namedItem("code_challenge_method") as HTMLInputElement).value,
+      // I would love to leave that as a list, but such is the form of
+      // IndieAuth.  application/x-www-form-urlencoded doesn't have
+      // lists, so scopes are space-separated instead. It is annoying.
+      scope: scopes.length > 0 ? scopes.join(" ") : undefined,
+    };
+
+    let credential = null;
+    switch ((form.elements.namedItem("auth_method") as HTMLInputElement).value) {
+    case "password":
+      credential = (form.elements.namedItem("user_password") as HTMLInputElement).value;
+      if (credential.length == 0) {
+        alert("Please enter a password.")
+        return
+      }
+      break;
+    case "webauthn":
+      // credential = await webauthn_authenticate();
+      alert("WebAuthn isn't implemented yet!")
+      return
+      break
+    default:
+      alert("Please choose an authentication method.")
+      return
+    }
+
+    console.log("Authorization request:", authorization_request);
+    console.log("Authentication method:", credential);
+
+    const body = JSON.stringify({
+      request: authorization_request,
+      authorization_method: credential
+    });
+    console.log(body);
+    
+    const response = await fetch(form.action, {
+      method: form.method,
+      body: body,
+      headers: {
+        "Content-Type": "application/json"
+      }
+    });
+
+    if (response.ok) {
+      let location = response.headers.get("Location");
+      if (location != null) {
+        window.location.href = location
+      } else {
+        throw "Error: didn't return a location"
+      }
+    }
+  } else {
+    return
+  }
+
+}
diff --git a/kittybox-rs/templates/javascript/src/lib.ts b/kittybox-rs/templates/javascript/src/lib.ts
new file mode 100644
index 0000000..38ba65b
--- /dev/null
+++ b/kittybox-rs/templates/javascript/src/lib.ts
@@ -0,0 +1,3 @@
+export function unreachable(msg: string): never {
+  throw new Error(msg);
+}
diff --git a/kittybox-rs/templates/javascript/src/onboarding.ts b/kittybox-rs/templates/javascript/src/onboarding.ts
new file mode 100644
index 0000000..0b455eb
--- /dev/null
+++ b/kittybox-rs/templates/javascript/src/onboarding.ts
@@ -0,0 +1,120 @@
+const firstOnboardingCard = "intro";
+
+function switchOnboardingCard(card: string) {
+  (Array.from(document.querySelectorAll("form.onboarding > fieldset")) as HTMLElement[])
+    .map((node: HTMLElement) => {
+      if (node.id == card) {
+        node.style.display = "block";
+      } else {
+        node.style.display = "none";
+      }
+    });
+
+  (Array.from(document.querySelectorAll("form.onboarding > ul#progressbar > li")) as HTMLElement[])
+    .map(node => {
+      if (node.id == card) {
+        node.classList.add("active")
+      } else {
+        node.classList.remove("active")
+      }
+    });
+};
+
+interface Window {
+  kittybox_onboarding: {
+    switchOnboardingCard: (card: string) => void
+  }
+}
+
+window.kittybox_onboarding = {
+  switchOnboardingCard
+};
+
+(document.querySelector("form.onboarding > ul#progressbar") as HTMLElement).style.display = "";
+switchOnboardingCard(firstOnboardingCard);
+
+function switchCardOnClick(event: MouseEvent) {
+  if (event.target instanceof HTMLElement) {
+    if (event.target.dataset.card !== undefined) {
+      switchOnboardingCard(event.target.dataset.card)
+    }
+  }
+}
+
+function multiInputAddMore(event: (MouseEvent | { target: HTMLElement })) {
+  if (event.target instanceof HTMLElement) {
+    let parent = event.target.parentElement;
+    if (parent !== null) {
+      let template = (parent.querySelector("template") as HTMLTemplateElement).content.cloneNode(true);
+      parent.prepend(template);
+    }
+  }
+}
+
+(Array.from(
+  document.querySelectorAll(
+    "form.onboarding > fieldset button.switch_card"
+  )
+) as HTMLButtonElement[])
+  .map(button => {
+    button.addEventListener("click", switchCardOnClick)
+  });
+
+(Array.from(
+  document.querySelectorAll(
+    "form.onboarding > fieldset div.multi_input > button.add_more"
+  )
+) as HTMLButtonElement[])
+  .map(button => {
+    button.addEventListener("click", multiInputAddMore)
+    multiInputAddMore({ target: button });
+  });
+
+const form = document.querySelector("form.onboarding") as HTMLFormElement;
+console.log(form);
+form.onsubmit = async (event: SubmitEvent) => {
+    console.log(event);
+    event.preventDefault();
+    const form = event.target as HTMLFormElement;
+    const json = {
+        user: {
+          type: ["h-card"],
+          properties: {
+            name: [(form.querySelector("#hcard_name") as HTMLInputElement).value],
+            pronoun: (Array.from(
+              form.querySelectorAll("#hcard_pronouns")
+            ) as HTMLInputElement[])
+              .map(input => input.value).filter(i => i != ""),
+            url: (Array.from(form.querySelectorAll("#hcard_url")) as HTMLInputElement[])
+              .map(input => input.value).filter(i => i != ""),
+            note: [(form.querySelector("#hcard_note") as HTMLInputElement).value]
+          }
+        },
+        first_post: {
+          type: ["h-entry"],
+          properties: {
+            content: [(form.querySelector("#first_post_content") as HTMLTextAreaElement).value]
+          }
+        },
+      blog_name: (form.querySelector("#blog_name") as HTMLInputElement).value,
+      feeds: (Array.from(
+        form.querySelectorAll(".multi_input#custom_feeds > fieldset.feed")
+      ) as HTMLElement[])
+        .map(form => {
+          return {
+            name: (form.querySelector("#feed_name") as HTMLInputElement).value,
+            slug: (form.querySelector("#feed_slug") as HTMLInputElement).value
+          }
+        }).filter(feed => feed.name == "" || feed.slug == "")
+    };
+
+    await fetch("/.kittybox/onboarding", {
+        method: "POST",
+        body: JSON.stringify(json),
+        headers: { "Content-Type": "application/json" }
+    }).then(response => {
+        if (response.status == 201) {
+            window.location.href = window.location.href;
+        }
+    })
+}
diff --git a/kittybox-rs/templates/javascript/src/webauthn/register.ts b/kittybox-rs/templates/javascript/src/webauthn/register.ts
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/kittybox-rs/templates/javascript/src/webauthn/register.ts
diff --git a/kittybox-rs/templates/javascript/tsconfig.json b/kittybox-rs/templates/javascript/tsconfig.json
new file mode 100644
index 0000000..18b94c7
--- /dev/null
+++ b/kittybox-rs/templates/javascript/tsconfig.json
@@ -0,0 +1,104 @@
+{
+  "compilerOptions": {
+    /* Visit https://aka.ms/tsconfig to read more about this file */
+
+    /* Projects */
+    // "incremental": true,                              /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
+    // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
+    // "tsBuildInfoFile": "./.tsbuildinfo",              /* Specify the path to .tsbuildinfo incremental compilation file. */
+    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects. */
+    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
+    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
+
+    /* Language and Environment */
+    "target": "es2022",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
+    // "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+    // "jsx": "preserve",                                /* Specify what JSX code is generated. */
+    // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
+    // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
+    // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
+    // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+    // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
+    // "reactNamespace": "",                             /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
+    // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
+    // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
+    // "moduleDetection": "auto",                        /* Control what method is used to detect module-format JS files. */
+
+    /* Modules */
+    "module": "es2022",                                     /* Specify what module code is generated. */
+    // "rootDir": "./",                                  /* Specify the root folder within your source files. */
+    // "moduleResolution": "node",                       /* Specify how TypeScript looks up a file from a given module specifier. */
+    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
+    // "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
+    // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
+    // "typeRoots": [],                                  /* Specify multiple folders that act like './node_modules/@types'. */
+    // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
+    // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
+    // "moduleSuffixes": [],                             /* List of file name suffixes to search when resolving a module. */
+    // "resolveJsonModule": true,                        /* Enable importing .json files. */
+    // "noResolve": true,                                /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
+
+    /* JavaScript Support */
+    // "allowJs": true,                                  /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
+    // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
+    // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
+
+    /* Emit */
+    // "declaration": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
+    // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
+    // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
+    // "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
+    // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
+    "outDir": "./dist",                                  /* Specify an output folder for all emitted files. */
+    // "removeComments": true,                           /* Disable emitting comments. */
+    // "noEmit": true,                                   /* Disable emitting files from a compilation. */
+    // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+    // "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types. */
+    // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+    // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
+    // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
+    // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
+    // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
+    // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+    // "newLine": "crlf",                                /* Set the newline character for emitting files. */
+    // "stripInternal": true,                            /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
+    // "noEmitHelpers": true,                            /* Disable generating custom helper functions like '__extends' in compiled output. */
+    // "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
+    // "preserveConstEnums": true,                       /* Disable erasing 'const enum' declarations in generated code. */
+    // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
+    // "preserveValueImports": true,                     /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
+
+    /* Interop Constraints */
+    // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
+    // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
+    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
+    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
+
+    /* Type Checking */
+    "strict": true,                                      /* Enable all strict type-checking options. */
+    // "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied 'any' type. */
+    // "strictNullChecks": true,                         /* When type checking, take into account 'null' and 'undefined'. */
+    // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+    // "strictBindCallApply": true,                      /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
+    // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
+    // "noImplicitThis": true,                           /* Enable error reporting when 'this' is given the type 'any'. */
+    // "useUnknownInCatchVariables": true,               /* Default catch clause variables as 'unknown' instead of 'any'. */
+    // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
+    // "noUnusedLocals": true,                           /* Enable error reporting when local variables aren't read. */
+    // "noUnusedParameters": true,                       /* Raise an error when a function parameter isn't read. */
+    // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
+    // "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
+    // "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
+    // "noUncheckedIndexedAccess": true,                 /* Add 'undefined' to a type when accessed using an index. */
+    // "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
+    // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type. */
+    // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
+    // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
+
+    /* Completeness */
+    // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
+    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
+  },
+  "include": ["src/**/*"]
+}
diff --git a/kittybox-rs/templates/src/lib.rs b/kittybox-rs/templates/src/lib.rs
index d58e831..5b3a8df 100644
--- a/kittybox-rs/templates/src/lib.rs
+++ b/kittybox-rs/templates/src/lib.rs
@@ -9,6 +9,37 @@ pub use login::LoginPage;
 mod mf2;
 pub use mf2::{Entry, VCard, Feed, Food, POSTS_PER_PAGE};
 
+pub mod assets {
+    use axum::response::{IntoResponse, Response};
+    use axum::extract::Path;
+    use axum::http::StatusCode;
+    use axum::http::header::{CONTENT_TYPE, CACHE_CONTROL};
+
+    const ASSETS: include_dir::Dir<'static> = include_dir::include_dir!("$OUT_DIR");
+    const CACHE_FOR_A_DAY: &str = "max-age=86400";
+
+    pub async fn statics(Path(path): Path<String>) -> Response {
+
+        let content_type: &'static str = if path.ends_with(".js") {
+            "application/javascript"
+        } else if path.ends_with(".css") {
+            "text/css"
+        } else if path.ends_with(".html") {
+            "text/html; charset=\"utf-8\""
+        } else {
+            "application/octet-stream"
+        };
+
+        match ASSETS.get_file(path) {
+            Some(file) => (StatusCode::OK,
+                           [(CONTENT_TYPE, content_type),
+                            (CACHE_CONTROL, CACHE_FOR_A_DAY)],
+                           file.contents()).into_response(),
+            None => StatusCode::NOT_FOUND.into_response()
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use faker_rand::en_us::internet::Domain;