Repository: importantimport/hatsu (language: rust)

Issue:
Frontend
Hatsu requires a front-end to implement [Webmention Backfeed](https://hatsu.cli.rs/users/backfeed-based-on-webmention.html).

This part is a bit more complicated, so I expect to have it done before `v1.0.0`.

Stacks:

- [Leptos](https://github.com/leptos-rs/leptos)
- ~~Style: TODO~~ ~~[Stylers](https://github.com/abishekatp/stylers)~~

Paths (temporary):

- hatsu.local/@example.com (user, `/u/example.com => /@example.com`)
- hatsu.local/@example.com/foo/bar (post, `/o/https://example.com/foo/bar => /@example.com/foo/bar`)


Discussion / hints:
I expect to start writing the frontend at `v0.3.0`.

It should be:

`features = ["frontend"]` - enable by default, can be disable manually when compiling locally so that it doesn't contain any frontend content

`./hatsu --frontend=false` - enable by default, disable frontend if passed `--frontend=false` as a argument
Would it be possible to help design the frontend?
> Would it be possible to help design the frontend?

Since Leptos doesn't have a proper component library, for now, I'm considering using [`@material/web`](https://github.com/material-components/material-web).
I meant like design things page by page, not necessarily components. Also, I recently looked into using that for one of my projects and found it to be woefully incomplete unfortunately, see the [Roadmap](https://github.com/material-components/material-web/blob/main/docs/roadmap.md)
> I meant like design things page by page, not necessarily components.

I welcome any help. For now, the frontend of Hatsu doesn't need to be too complex, so it should be a Mobile-first design. It's just displaying content (maybe some simple interactions and a “view original page” button)

> Also, I recently looked into using that for one of my projects and found it to be woefully incomplete unfortunately, see the [Roadmap](https://github.com/material-components/material-web/blob/main/docs/roadmap.md?rgh-link-date=2024-05-13T10%3A57%3A47Z)

As an alternative, it may be possible to use [mdui](https://github.com/zdhxiong/mdui), which currently offers more components
![image](https://github.com/importantimport/hatsu/assets/19589006/670a2e57-fa5a-47ac-8274-f4c12df6e09b)


just a quick mockup i made in a few mins, what are your thoughts?
> just a quick mockup i made in a few mins, what are your thoughts?

Posts should ideally be on a separate page.
could we have both a permalink page that gets rendered as a drawer if you click from the UI?
> could we have both a permalink page that gets rendered as a drawer if you click from the UI?

My idea is still to make it as simple as possible, so we don't have to put in too much time for parts that don't matter.
That makes total sense - I was just thinking from a UX perspective but simplicity is more valuable here.

I fleshed out the design a bit more:

![image](https://github.com/importantimport/hatsu/assets/19589006/89aedd20-53a8-46ad-a175-435005cb5463)


here's the figma file: https://www.figma.com/file/Tc4glSBiGnwg6crUva5uKX/hatsu?type=design&node-id=0%3A1&mode=design&t=8xFSAjkRLoe9fYWj-1
For post comments / reactions, I'll use [KKna](https://github.com/importantimport/kkna) directly, they're all expected to use `@material/web`, so it'll look very consistent
Makes sense to me
The homepage should only show the Hatsu version (and optional instance info), and provide a webring-like feel to the user list on the other pages
I think it would make sense to show a list of feeds though. I do like the webring idea too.
> I think it would make sense to show a list of feeds though. I do like the webring idea too.

The main thing I meant was to move the list of users to a separate page, with the home page only being used to display basic information.
hmmm - i think the homepage being the index makes logical sense more than a separate index page
I expect to write the frontend in the following order:

- Home page with only basic information
- User page
- Post page
- Separate reply page (for Webmention Backfeed feature)

The other pages are low-priority.
> Separate reply page (for Webmention Backfeed feature)

Can't there just be multiple h-entries on the page and link with an ID?
> Can't there just be multiple h-entries on the page and link with an ID?

This means it's too complicated to implement it in KKna and SSR it.
updated things a bit: 
![image](https://github.com/importantimport/hatsu/assets/19589006/fe3abf52-1ae0-436b-bfd7-e109785bd311)


Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index e2a621d..9280839 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1442,6 +1442,7 @@ dependencies = [
  "hatsu_api_apub",
  "hatsu_api_mastodon",
  "hatsu_cron",
+ "hatsu_frontend",
  "hatsu_nodeinfo",
  "hatsu_utils",
  "hatsu_well_known",
@@ -1508,6 +1509,17 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "hatsu_frontend"
+version = "0.3.0"
+dependencies = [
+ "activitypub_federation",
+ "axum",
+ "hatsu_utils",
+ "maud",
+ "serde_json",
+]
+
 [[package]]
 name = "hatsu_nodeinfo"
 version = "0.3.0"
@@ -2025,6 +2037,30 @@ version = "0.7.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
 
+[[package]]
+name = "maud"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa"
+dependencies = [
+ "axum-core",
+ "http",
+ "itoa",
+ "maud_macros",
+]
+
+[[package]]
+name = "maud_macros"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa453238ec218da0af6b11fc5978d3b5c3a45ed97b722391a2a11f3306274e18"
+dependencies = [
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.85",
+]
+
 [[package]]
 name = "md-5"
 version = "0.10.6"
diff --git a/Cargo.toml b/Cargo.toml
index 4a23311..4695b66 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -60,6 +60,7 @@ members = [
     "crates/db_migration",
     "crates/db_schema",
     "crates/feed",
+    "crates/frontend",
     "crates/nodeinfo",
     "crates/tracing",
     "crates/utils",
@@ -77,6 +78,7 @@ hatsu_cron = { path = "./crates/cron" }
 hatsu_db_migration = { path = "./crates/db_migration" }
 hatsu_db_schema = { path = "./crates/db_schema" }
 hatsu_feed = { path = "./crates/feed" }
+hatsu_frontend = { path = "./crates/frontend" }
 hatsu_nodeinfo = { path = "./crates/nodeinfo" }
 hatsu_tracing = { path = "./crates/tracing" }
 hatsu_utils = { path = "./crates/utils" }
@@ -101,6 +103,7 @@ enum_delegate = "0.2"
 feed-rs = "2.1"
 futures = "0.3"
 markdown = "1.0.0-alpha"
+maud = { version = "0.26", features = ["axum"] }
 # https://github.com/LemmyNet/activitypub-federation-rust/blob/a0e0c54b571a8c9f11e5646c4117fb7ff2cf1d70/Cargo.toml#L40-L44
 reqwest = { version = "0.12", default-features = false, features = [
     "json",
diff --git a/crates/api_admin/src/routes/block_url.rs b/crates/api_admin/src/routes/block_url.rs
index b2c7002..fd1b27e 100644
--- a/crates/api_admin/src/routes/block_url.rs
+++ b/crates/api_admin/src/routes/block_url.rs
@@ -28,7 +28,7 @@ pub async fn block_url(
 ) -> Result<(StatusCode, Json<BlockUrlResult>), AppError> {
     match &query.url {
         url if url.query().is_some() => Err(AppError::new(
-            format!("wrong url: {} (can't contain search params)", url),
+            format!("wrong url: {url} (can't contain search params)"),
             None,
             Some(StatusCode::BAD_REQUEST),
         )),
diff --git a/crates/backend/Cargo.toml b/crates/backend/Cargo.toml
index 6321381..9880269 100644
--- a/crates/backend/Cargo.toml
+++ b/crates/backend/Cargo.toml
@@ -21,6 +21,7 @@ hatsu_api_admin = { workspace = true }
 hatsu_api_apub = { workspace = true }
 hatsu_api_mastodon = { workspace = true }
 hatsu_cron = { workspace = true }
+hatsu_frontend = { workspace = true }
 hatsu_nodeinfo = { workspace = true }
 hatsu_well_known = { workspace = true }
 hatsu_utils = { workspace = true }
diff --git a/crates/backend/src/routes.rs b/crates/backend/src/routes.rs
index 260487d..d4fd6be 100644
--- a/crates/backend/src/routes.rs
+++ b/crates/backend/src/routes.rs
@@ -1,16 +1,10 @@
-use axum::{http::Response, routing::get, Json, Router};
-use hatsu_utils::AppEnv;
+use axum::{routing::get, Json, Router};
 use utoipa::OpenApi;
 use utoipa_axum::router::OpenApiRouter;
 use utoipa_scalar::{Scalar, Servable};
 
 use crate::{favicon, openapi::ApiDoc};
 
-// ./hatsu --version
-async fn root() -> Response<String> {
-    Response::new(AppEnv::info())
-}
-
 pub fn routes() -> Router {
     let (api_router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
         .merge(hatsu_api::routes())
@@ -28,9 +22,9 @@ pub fn routes() -> Router {
         .merge(Scalar::with_url("/scalar", api));
 
     let router = Router::new()
-        .route("/", get(root))
         .route("/favicon.ico", get(favicon::ico))
-        .route("/favicon.svg", get(favicon::svg));
+        .route("/favicon.svg", get(favicon::svg))
+        .merge(hatsu_frontend::routes());
 
     router.merge(api_router)
 }
diff --git a/crates/feed/src/user_feed.rs b/crates/feed/src/user_feed.rs
index f4f52ca..560868a 100644
--- a/crates/feed/src/user_feed.rs
+++ b/crates/feed/src/user_feed.rs
@@ -108,8 +108,8 @@ impl UserFeed {
             } => UserFeedTopLevel::parse_json_feed(url.clone()).await,
             Self {
                 atom: Some(url), ..
-            } => UserFeedTopLevel::parse_xml_feed(url.clone()).await,
-            Self { rss: Some(url), .. } => UserFeedTopLevel::parse_xml_feed(url.clone()).await,
+            }
+            | Self { rss: Some(url), .. } => UserFeedTopLevel::parse_xml_feed(url.clone()).await,
             Self {
                 json: None,
                 atom: None,
diff --git a/crates/frontend/Cargo.toml b/crates/frontend/Cargo.toml
new file mode 100644
index 0000000..24b0c59
--- /dev/null
+++ b/crates/frontend/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "hatsu_frontend"
+version.workspace = true
+edition.workspace = true
+publish.workspace = true
+readme.workspace = true
+license.workspace = true
+authors.workspace = true
+description.workspace = true
+documentation.workspace = true
+homepage.workspace = true
+repository.workspace = true
+
+[lib]
+name = "hatsu_frontend"
+path = "src/lib.rs"
+
+[dependencies]
+activitypub_federation = { workspace = true }
+axum = { workspace = true }
+hatsu_utils = { workspace = true }
+maud = { workspace = true }
+serde_json = { workspace = true }
diff --git a/crates/frontend/assets/custom.css b/crates/frontend/assets/custom.css
new file mode 100644
index 0000000..a003a4e
--- /dev/null
+++ b/crates/frontend/assets/custom.css
@@ -0,0 +1,24 @@
+body {
+    background-color: var(--md-sys-color-surface);
+    color: var(--md-sys-color-on-surface);
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+}
+
+header,
+main,
+footer {
+    width: 100%;
+    max-width: 48rem;
+}
+
+a {
+    color: var(--md-sys-color-primary, #6750A4);
+    text-decoration: none;
+}
+
+a:focus-visible,
+a:hover {
+    text-decoration: underline;
+}
\ No newline at end of file
diff --git a/crates/frontend/assets/dark.css b/crates/frontend/assets/dark.css
new file mode 100644
index 0000000..7759989
--- /dev/null
+++ b/crates/frontend/assets/dark.css
@@ -0,0 +1,53 @@
+@media (prefers-color-scheme: dark) {
+    :root {
+        --md-sys-color-primary: rgb(145 206 244);
+        --md-sys-color-surface-tint: rgb(145 206 244);
+        --md-sys-color-on-primary: rgb(0 52 74);
+        --md-sys-color-primary-container: rgb(0 76 106);
+        --md-sys-color-on-primary-container: rgb(197 231 255);
+        --md-sys-color-secondary: rgb(182 201 216);
+        --md-sys-color-on-secondary: rgb(32 51 62);
+        --md-sys-color-secondary-container: rgb(55 73 85);
+        --md-sys-color-on-secondary-container: rgb(210 229 244);
+        --md-sys-color-tertiary: rgb(203 193 233);
+        --md-sys-color-on-tertiary: rgb(51 44 76);
+        --md-sys-color-tertiary-container: rgb(73 66 99);
+        --md-sys-color-on-tertiary-container: rgb(231 222 255);
+        --md-sys-color-error: rgb(255 180 171);
+        --md-sys-color-on-error: rgb(105 0 5);
+        --md-sys-color-error-container: rgb(147 0 10);
+        --md-sys-color-on-error-container: rgb(255 218 214);
+        --md-sys-color-background: rgb(15 20 23);
+        --md-sys-color-on-background: rgb(223 227 231);
+        --md-sys-color-surface: rgb(15 20 23);
+        --md-sys-color-on-surface: rgb(223 227 231);
+        --md-sys-color-surface-variant: rgb(65 72 77);
+        --md-sys-color-on-surface-variant: rgb(193 199 206);
+        --md-sys-color-outline: rgb(139 146 151);
+        --md-sys-color-outline-variant: rgb(65 72 77);
+        --md-sys-color-shadow: rgb(0 0 0);
+        --md-sys-color-scrim: rgb(0 0 0);
+        --md-sys-color-inverse-surface: rgb(223 227 231);
+        --md-sys-color-inverse-on-surface: rgb(44 49 52);
+        --md-sys-color-inverse-primary: rgb(31 101 134);
+        --md-sys-color-primary-fixed: rgb(197 231 255);
+        --md-sys-color-on-primary-fixed: rgb(0 30 45);
+        --md-sys-color-primary-fixed-dim: rgb(145 206 244);
+        --md-sys-color-on-primary-fixed-variant: rgb(0 76 106);
+        --md-sys-color-secondary-fixed: rgb(210 229 244);
+        --md-sys-color-on-secondary-fixed: rgb(10 30 40);
+        --md-sys-color-secondary-fixed-dim: rgb(182 201 216);
+        --md-sys-color-on-secondary-fixed-variant: rgb(55 73 85);
+        --md-sys-color-tertiary-fixed: rgb(231 222 255);
+        --md-sys-color-on-tertiary-fixed: rgb(29 23 53);
+        --md-sys-color-tertiary-fixed-dim: rgb(203 193 233);
+        --md-sys-color-on-tertiary-fixed-variant: rgb(73 66 99);
+        --md-sys-color-surface-dim: rgb(15 20 23);
+        --md-sys-color-surface-bright: rgb(53 58 61);
+        --md-sys-color-surface-container-lowest: rgb(10 15 18);
+        --md-sys-color-surface-container-low: rgb(24 28 31);
+        --md-sys-color-surface-container: rgb(28 32 36);
+        --md-sys-color-surface-container-high: rgb(38 43 46);
+        --md-sys-color-surface-container-highest: rgb(49 53 57);
+    }
+}
\ No newline at end of file
diff --git a/crates/frontend/assets/light.css b/crates/frontend/assets/light.css
new file mode 100644
index 0000000..be30808
--- /dev/null
+++ b/crates/frontend/assets/light.css
@@ -0,0 +1,53 @@
+@media (prefers-color-scheme: light) {
+    :root {
+        --md-sys-color-primary: rgb(31 101 134);
+        --md-sys-color-surface-tint: rgb(31 101 134);
+        --md-sys-color-on-primary: rgb(255 255 255);
+        --md-sys-color-primary-container: rgb(197 231 255);
+        --md-sys-color-on-primary-container: rgb(0 30 45);
+        --md-sys-color-secondary: rgb(78 97 109);
+        --md-sys-color-on-secondary: rgb(255 255 255);
+        --md-sys-color-secondary-container: rgb(210 229 244);
+        --md-sys-color-on-secondary-container: rgb(10 30 40);
+        --md-sys-color-tertiary: rgb(97 89 124);
+        --md-sys-color-on-tertiary: rgb(255 255 255);
+        --md-sys-color-tertiary-container: rgb(231 222 255);
+        --md-sys-color-on-tertiary-container: rgb(29 23 53);
+        --md-sys-color-error: rgb(186 26 26);
+        --md-sys-color-on-error: rgb(255 255 255);
+        --md-sys-color-error-container: rgb(255 218 214);
+        --md-sys-color-on-error-container: rgb(65 0 2);
+        --md-sys-color-background: rgb(246 250 254);
+        --md-sys-color-on-background: rgb(24 28 31);
+        --md-sys-color-surface: rgb(246 250 254);
+        --md-sys-color-on-surface: rgb(24 28 31);
+        --md-sys-color-surface-variant: rgb(221 227 234);
+        --md-sys-color-on-surface-variant: rgb(65 72 77);
+        --md-sys-color-outline: rgb(113 120 126);
+        --md-sys-color-outline-variant: rgb(193 199 206);
+        --md-sys-color-shadow: rgb(0 0 0);
+        --md-sys-color-scrim: rgb(0 0 0);
+        --md-sys-color-inverse-surface: rgb(44 49 52);
+        --md-sys-color-inverse-on-surface: rgb(237 241 246);
+        --md-sys-color-inverse-primary: rgb(145 206 244);
+        --md-sys-color-primary-fixed: rgb(197 231 255);
+        --md-sys-color-on-primary-fixed: rgb(0 30 45);
+        --md-sys-color-primary-fixed-dim: rgb(145 206 244);
+        --md-sys-color-on-primary-fixed-variant: rgb(0 76 106);
+        --md-sys-color-secondary-fixed: rgb(210 229 244);
+        --md-sys-color-on-secondary-fixed: rgb(10 30 40);
+        --md-sys-color-secondary-fixed-dim: rgb(182 201 216);
+        --md-sys-color-on-secondary-fixed-variant: rgb(55 73 85);
+        --md-sys-color-tertiary-fixed: rgb(231 222 255);
+        --md-sys-color-on-tertiary-fixed: rgb(29 23 53);
+        --md-sys-color-tertiary-fixed-dim: rgb(203 193 233);
+        --md-sys-color-on-tertiary-fixed-variant: rgb(73 66 99);
+        --md-sys-color-surface-dim: rgb(215 218 223);
+        --md-sys-color-surface-bright: rgb(246 250 254);
+        --md-sys-color-surface-container-lowest: rgb(255 255 255);
+        --md-sys-color-surface-container-low: rgb(240 244 248);
+        --md-sys-color-surface-container: rgb(235 238 243);
+        --md-sys-color-surface-container-high: rgb(229 232 237);
+        --md-sys-color-surface-container-highest: rgb(223 227 231);
+    }
+}
\ No newline at end of file
diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs
new file mode 100644
index 0000000..140c7b9
--- /dev/null
+++ b/crates/frontend/src/lib.rs
@@ -0,0 +1,4 @@
+mod pages;
+pub mod partials;
+
+pub use pages::routes;
diff --git a/crates/frontend/src/pages/home.rs b/crates/frontend/src/pages/home.rs
new file mode 100644
index 0000000..a829657
--- /dev/null
+++ b/crates/frontend/src/pages/home.rs
@@ -0,0 +1,41 @@
+use activitypub_federation::config::Data;
+use hatsu_utils::AppData;
+use maud::{html, Markup};
+
+use crate::partials::layout;
+
+pub async fn home(data: Data<AppData>) -> Markup {
+    let title = data
+        .env
+        .hatsu_node_name
+        .clone()
+        .unwrap_or_else(|| String::from("Hatsu"));
+
+    layout(
+        &html! {
+            @if let Some(description) = &data.env.hatsu_node_description {
+                h2 class="md-typescale-title-large" style="margin-top: 0" { "About this instance" }
+                p style="margin: 0" { (description) }
+                br;
+                md-divider {}
+                h2 class="md-typescale-title-large" { "What is this?" }
+            } @else {
+                h2 class="md-typescale-title-large" style="margin-top: 0"  { "What is this?" }
+            }
+            p style="margin: 0" { r#"
+                The web page you're reading right now is served by an instance of Hatsu,
+                a self-hosted bridge that interacts with Fediverse on behalf of static site.
+            "# }
+            br;
+            md-divider {}
+            h2 class="md-typescale-title-large" { "Register an Account on " (title) }
+            p style="margin: 0" {
+                "New account registration is currently "
+                // TODO: add registration status
+                b { "closed" }
+                "."
+            }
+        },
+        &data,
+    )
+}
diff --git a/crates/frontend/src/pages/mod.rs b/crates/frontend/src/pages/mod.rs
new file mode 100644
index 0000000..72890ec
--- /dev/null
+++ b/crates/frontend/src/pages/mod.rs
@@ -0,0 +1,7 @@
+use axum::{routing::get, Router};
+
+mod home;
+
+pub fn routes() -> Router {
+    Router::new().route("/", get(home::home))
+}
diff --git a/crates/frontend/src/partials/layout.rs b/crates/frontend/src/partials/layout.rs
new file mode 100644
index 0000000..5c27040
--- /dev/null
+++ b/crates/frontend/src/partials/layout.rs
@@ -0,0 +1,78 @@
+use activitypub_federation::config::Data;
+use hatsu_utils::{AppData, AppEnv};
+use maud::{html, Markup, PreEscaped, DOCTYPE};
+use serde_json::json;
+
+static CSS_CUSTOM: &str = include_str!("../../assets/custom.css");
+static CSS_DARK: &str = include_str!("../../assets/dark.css");
+static CSS_LIGHT: &str = include_str!("../../assets/light.css");
+
+fn material_web() -> Markup {
+    html! {
+        link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet";
+        style {
+            (CSS_DARK)
+            "\n"
+            (CSS_LIGHT)
+            "\n"
+            (CSS_CUSTOM)
+        }
+        script type="importmap" {
+            (PreEscaped(json!({
+                "imports": {
+                  "@material/web/": "https://esm.run/@material/web@2.2.0/"
+                }
+            }).to_string()))
+        }
+        script type="module" {
+            r#"
+import '@material/web/divider/divider.js';
+
+import '@material/web/labs/card/filled-card.js';
+
+import { styles as typescaleStyles } from '@material/web/typography/md-typescale-styles.js';
+document.adoptedStyleSheets.push(typescaleStyles.styleSheet);
+            "#
+        }
+    }
+}
+
+pub fn layout(body: &Markup, data: &Data<AppData>) -> Markup {
+    let title = data
+        .env
+        .hatsu_node_name
+        .clone()
+        .unwrap_or_else(|| String::from("Hatsu"));
+
+    html! {
+        (DOCTYPE)
+        html lang="en" {
+            head {
+                meta charset="utf-8";
+                meta name="viewport" content="width=device-width, initial-scale=1.0";
+                link rel="icon" href="/favicon.ico" sizes="48x48";
+                link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml";
+                title { (&title) }
+                (material_web())
+            }
+            body class="md-typescale-body-large" {
+                header {
+                    h2 class="md-typescale-title-large" style="margin: 1rem" { (title) }
+                }
+                main {
+                    md-filled-card style="padding: 1rem" {
+                        (body)
+                    }
+                }
+                footer {
+                    div style="padding: 1rem; display: flex; gap: 0.5rem" {
+                        img src="favicon.svg" height="24" width="24";
+                        a href="https://github.com/importantimport/hatsu" target="_blank" rel="noopener"  {
+                            (AppEnv::info())
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/crates/frontend/src/partials/mod.rs b/crates/frontend/src/partials/mod.rs
new file mode 100644
index 0000000..16556de
--- /dev/null
+++ b/crates/frontend/src/partials/mod.rs
@@ -0,0 +1,3 @@
+mod layout;
+
+pub use layout::layout;
diff --git a/flake.nix b/flake.nix
index c1b5396..6b265db 100644
--- a/flake.nix
+++ b/flake.nix
@@ -12,20 +12,41 @@
     fenix.inputs.nixpkgs.follows = "nixpkgs";
   };
 
-  outputs = inputs@{ crane, fenix, flake-parts, nixpkgs, ... }:
+  outputs =
+    inputs@{
+      crane,
+      fenix,
+      flake-parts,
+      nixpkgs,
+      ...
+    }:
     flake-parts.lib.mkFlake { inherit inputs; } {
       imports = [ ];
-      systems = [ "x86_64-linux" "aarch64-linux" ];
+      systems = [
+        "x86_64-linux"
+        "aarch64-linux"
+      ];
 
-      perSystem = { config, self', inputs', lib, pkgs, system, ... }:
+      perSystem =
+        {
+          config,
+          self',
+          inputs',
+          lib,
+          pkgs,
+          system,
+          ...
+        }:
         let
-          toolchain = with fenix.packages.${system}; combine [
-            complete.toolchain
-            targets.aarch64-unknown-linux-gnu.latest.rust-std
-            targets.aarch64-unknown-linux-musl.latest.rust-std
-            targets.x86_64-unknown-linux-gnu.latest.rust-std
-            targets.x86_64-unknown-linux-musl.latest.rust-std
-          ];
+          toolchain =
+            with fenix.packages.${system};
+            combine [
+              complete.toolchain
+              targets.aarch64-unknown-linux-gnu.latest.rust-std
+              targets.aarch64-unknown-linux-musl.latest.rust-std
+              targets.x86_64-unknown-linux-gnu.latest.rust-std
+              targets.x86_64-unknown-linux-musl.latest.rust-std
+            ];
 
           craneLib = (crane.mkLib pkgs).overrideToolchain toolchain;
 
@@ -33,9 +54,11 @@
           src = lib.cleanSourceWith {
             name = "source";
             src = ./.;
-            filter = path: type:
+            filter =
+              path: type:
               (lib.hasInfix "/contrib/" path)
               || (lib.hasInfix "/crates/backend/assets/" path)
+              || (lib.hasInfix "/crates/frontend/assets/" path)
               || (craneLib.filterCargoSources path type);
           };
 
@@ -50,38 +73,59 @@
             inherit src;
           };
 
-          cargoClippy = craneLib.cargoClippy (commonArgs // {
-            inherit cargoArtifacts;
-            cargoClippyExtraArgs = "--all-targets -- -W clippy::pedantic -W clippy::nursery -A clippy::missing-errors-doc -A clippy::module_name_repetitions";
-          });
+          cargoClippy = craneLib.cargoClippy (
+            commonArgs
+            // {
+              inherit cargoArtifacts;
+              cargoClippyExtraArgs = "--all-targets -- -W clippy::pedantic -W clippy::nursery -A clippy::missing-errors-doc -A clippy::module_name_repetitions";
+            }
+          );
 
-          cargoNextest = craneLib.cargoNextest (commonArgs // {
-            inherit cargoArtifacts;
-            partitions = 1;
-            partitionType = "count";
-          });
+          cargoNextest = craneLib.cargoNextest (
+            commonArgs
+            // {
+              inherit cargoArtifacts;
+              partitions = 1;
+              partitionType = "count";
+            }
+          );
 
           # TODO: cargoHakari = craneLib.mkCargoDerivation
           # https://crane.dev/examples/quick-start-workspace.html
 
-          buildHatsu = args:
-            craneLib.buildPackage (commonArgs // {
-              inherit cargoArtifacts;
-            } // lib.optionalAttrs (!isNull args) args);
+          buildHatsu =
+            args:
+            craneLib.buildPackage (
+              commonArgs
+              // {
+                inherit cargoArtifacts;
+              }
+              // lib.optionalAttrs (!isNull args) args
+            );
 
           hatsu = buildHatsu { };
         in
         {
           checks = {
-            inherit cargoFmt cargoClippy cargoNextest hatsu;
+            inherit
+              cargoFmt
+              cargoClippy
+              cargoNextest
+              hatsu
+              ;
           };
 
           packages =
             let
               aarch64Args =
-                let inherit (pkgs.pkgsCross.aarch64-multiplatform.stdenv) cc;
-                in {
-                  depsBuildBuild = [ cc pkgs.qemu ];
+                let
+                  inherit (pkgs.pkgsCross.aarch64-multiplatform.stdenv) cc;
+                in
+                {
+                  depsBuildBuild = [
+                    cc
+                    pkgs.qemu
+                  ];
 
                   HOST_CC = "${cc.nativePrefix}cc";
                   TARGET_CC = "${cc.targetPrefix}cc";
@@ -91,8 +135,10 @@
                   CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER = "${cc}/bin/${cc.targetPrefix}cc";
                 };
               x86_64Args =
-                let inherit (pkgs.stdenv) cc;
-                in {
+                let
+                  inherit (pkgs.stdenv) cc;
+                in
+                {
                   depsBuildBuild = [ cc ];
                   HOST_CC = "${cc.nativePrefix}cc";
                   TARGET_CC = "${cc.targetPrefix}cc";
@@ -105,39 +151,55 @@
             in
             {
               default = hatsu;
-              aarch64-unknown-linux-gnu = buildHatsu ({
-                CARGO_BUILD_TARGET = "aarch64-unknown-linux-gnu";
-              } // aarch64Args);
-              aarch64-unknown-linux-musl = buildHatsu ({
-                CARGO_BUILD_TARGET = "aarch64-unknown-linux-musl";
-              } // aarch64Args // muslArgs);
-              x86_64-unknown-linux-gnu = buildHatsu ({
-                CARGO_BUILD_TARGET = "x86_64-unknown-linux-gnu";
-              } // x86_64Args);
-              x86_64-unknown-linux-musl = buildHatsu ({
-                CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl";
-              } // x86_64Args // muslArgs);
+              aarch64-unknown-linux-gnu = buildHatsu (
+                {
+                  CARGO_BUILD_TARGET = "aarch64-unknown-linux-gnu";
+                }
+                // aarch64Args
+              );
+              aarch64-unknown-linux-musl = buildHatsu (
+                {
+                  CARGO_BUILD_TARGET = "aarch64-unknown-linux-musl";
+                }
+                // aarch64Args
+                // muslArgs
+              );
+              x86_64-unknown-linux-gnu = buildHatsu (
+                {
+                  CARGO_BUILD_TARGET = "x86_64-unknown-linux-gnu";
+                }
+                // x86_64Args
+              );
+              x86_64-unknown-linux-musl = buildHatsu (
+                {
+                  CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl";
+                }
+                // x86_64Args
+                // muslArgs
+              );
             };
           devShells.default = craneLib.devShell {
             # checks = self'.checks.${system};
             inputsFrom = [ hatsu ];
-            packages = (with pkgs; [
-              mdbook # ./docs/
-
-              # cargo-*
-              cargo-hakari
-              cargo-nextest
-              cargo-watch
-
-              # sea-orm
-              sea-orm-cli
-
-              just
-              # mold
-              # sccache
-            ]) ++ (with fenix.packages.${system}; [
-              rust-analyzer
-            ]);
+            packages =
+              (with pkgs; [
+                mdbook # ./docs/
+
+                # cargo-*
+                cargo-hakari
+                cargo-nextest
+                cargo-watch
+
+                # sea-orm
+                sea-orm-cli
+
+                just
+                # mold
+                # sccache
+              ])
+              ++ (with fenix.packages.${system}; [
+                rust-analyzer
+              ]);
           };
         };
       flake = {


Test patch: