max / multithreaded
15 files changed,
+866 insertions,
-215 deletions
| @@ -1367,14 +1367,14 @@ dependencies = [ | |||
| 1367 | 1367 | ||
| 1368 | 1368 | [[package]] | |
| 1369 | 1369 | name = "mt-core" | |
| 1370 | - | version = "0.2.0" | |
| 1370 | + | version = "0.2.1" | |
| 1371 | 1371 | dependencies = [ | |
| 1372 | 1372 | "chrono", | |
| 1373 | 1373 | ] | |
| 1374 | 1374 | ||
| 1375 | 1375 | [[package]] | |
| 1376 | 1376 | name = "mt-db" | |
| 1377 | - | version = "0.2.0" | |
| 1377 | + | version = "0.2.1" | |
| 1378 | 1378 | dependencies = [ | |
| 1379 | 1379 | "chrono", | |
| 1380 | 1380 | "sqlx", | |
| @@ -1384,7 +1384,7 @@ dependencies = [ | |||
| 1384 | 1384 | ||
| 1385 | 1385 | [[package]] | |
| 1386 | 1386 | name = "multithreaded" | |
| 1387 | - | version = "0.2.0" | |
| 1387 | + | version = "0.2.1" | |
| 1388 | 1388 | dependencies = [ | |
| 1389 | 1389 | "ammonia", | |
| 1390 | 1390 | "askama", |
| @@ -7,7 +7,7 @@ members = [ | |||
| 7 | 7 | default-members = ["."] | |
| 8 | 8 | ||
| 9 | 9 | [workspace.package] | |
| 10 | - | version = "0.2.0" | |
| 10 | + | version = "0.2.2" | |
| 11 | 11 | edition = "2024" | |
| 12 | 12 | license-file = "LICENSE" | |
| 13 | 13 |
| @@ -64,7 +64,11 @@ pub(super) async fn admin_dashboard( | |||
| 64 | 64 | ||
| 65 | 65 | Ok(AdminDashboardTemplate { | |
| 66 | 66 | csrf_token, | |
| 67 | - | session_user: Some(TemplateSessionUser { username: admin.username }), | |
| 67 | + | session_user: Some(TemplateSessionUser { | |
| 68 | + | is_platform_admin: true, | |
| 69 | + | username: admin.username, | |
| 70 | + | }), | |
| 71 | + | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 68 | 72 | communities, | |
| 69 | 73 | users, | |
| 70 | 74 | search_query, |
| @@ -43,12 +43,14 @@ pub(super) async fn forum_directory( | |||
| 43 | 43 | .collect(); | |
| 44 | 44 | ||
| 45 | 45 | let session_user = session_user.map(|u| TemplateSessionUser { | |
| 46 | + | is_platform_admin: state.config.platform_admin_id == Some(u.user_id), | |
| 46 | 47 | username: u.username, | |
| 47 | 48 | }); | |
| 48 | 49 | ||
| 49 | 50 | ForumDirectoryTemplate { | |
| 50 | 51 | csrf_token, | |
| 51 | 52 | session_user, | |
| 53 | + | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 52 | 54 | communities, | |
| 53 | 55 | } | |
| 54 | 56 | } | |
| @@ -104,12 +106,14 @@ pub(super) async fn project_forum( | |||
| 104 | 106 | let mod_or_owner = is_mod_or_owner(&role); | |
| 105 | 107 | ||
| 106 | 108 | let session_user = session_user.map(|u| TemplateSessionUser { | |
| 109 | + | is_platform_admin: state.config.platform_admin_id == Some(u.user_id), | |
| 107 | 110 | username: u.username, | |
| 108 | 111 | }); | |
| 109 | 112 | ||
| 110 | 113 | Ok(CommunityTemplate { | |
| 111 | 114 | csrf_token, | |
| 112 | 115 | session_user, | |
| 116 | + | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 113 | 117 | community_name: community.name, | |
| 114 | 118 | community_slug: community.slug, | |
| 115 | 119 | community_description: community.description, | |
| @@ -155,6 +159,7 @@ pub(super) async fn community_members( | |||
| 155 | 159 | .collect(); | |
| 156 | 160 | ||
| 157 | 161 | let session_user = session_user.map(|u| TemplateSessionUser { | |
| 162 | + | is_platform_admin: state.config.platform_admin_id == Some(u.user_id), | |
| 158 | 163 | username: u.username, | |
| 159 | 164 | }); | |
| 160 | 165 | ||
| @@ -244,6 +249,7 @@ pub(super) async fn category( | |||
| 244 | 249 | .collect(); | |
| 245 | 250 | ||
| 246 | 251 | let session_user = session_user.map(|u| TemplateSessionUser { | |
| 252 | + | is_platform_admin: state.config.platform_admin_id == Some(u.user_id), | |
| 247 | 253 | username: u.username, | |
| 248 | 254 | }); | |
| 249 | 255 | ||
| @@ -364,6 +370,7 @@ pub(super) async fn thread( | |||
| 364 | 370 | .collect(); | |
| 365 | 371 | ||
| 366 | 372 | let session_user = session_user.map(|u| TemplateSessionUser { | |
| 373 | + | is_platform_admin: state.config.platform_admin_id == Some(u.user_id), | |
| 367 | 374 | username: u.username, | |
| 368 | 375 | }); | |
| 369 | 376 | ||
| @@ -417,12 +424,14 @@ pub(super) async fn new_thread( | |||
| 417 | 424 | .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; | |
| 418 | 425 | ||
| 419 | 426 | let session_user = session_user.map(|u| TemplateSessionUser { | |
| 427 | + | is_platform_admin: state.config.platform_admin_id == Some(u.user_id), | |
| 420 | 428 | username: u.username, | |
| 421 | 429 | }); | |
| 422 | 430 | ||
| 423 | 431 | Ok(NewThreadTemplate { | |
| 424 | 432 | csrf_token, | |
| 425 | 433 | session_user, | |
| 434 | + | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 426 | 435 | community_name: community.name, | |
| 427 | 436 | community_slug: slug, | |
| 428 | 437 | category_name: cat.name, | |
| @@ -615,7 +624,11 @@ pub(super) async fn edit_post_form( | |||
| 615 | 624 | ||
| 616 | 625 | Ok(EditPostTemplate { | |
| 617 | 626 | csrf_token, | |
| 618 | - | session_user: Some(TemplateSessionUser { username: user.username }), | |
| 627 | + | session_user: Some(TemplateSessionUser { | |
| 628 | + | is_platform_admin: state.config.platform_admin_id == Some(user.user_id), | |
| 629 | + | username: user.username, | |
| 630 | + | }), | |
| 631 | + | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 619 | 632 | community_name: post_data.community_name, | |
| 620 | 633 | community_slug: slug, | |
| 621 | 634 | category_name: post_data.category_name, | |
| @@ -811,7 +824,11 @@ pub(super) async fn edit_thread_form( | |||
| 811 | 824 | ||
| 812 | 825 | Ok(EditThreadTemplate { | |
| 813 | 826 | csrf_token, | |
| 814 | - | session_user: Some(TemplateSessionUser { username: user.username }), | |
| 827 | + | session_user: Some(TemplateSessionUser { | |
| 828 | + | is_platform_admin: state.config.platform_admin_id == Some(user.user_id), | |
| 829 | + | username: user.username, | |
| 830 | + | }), | |
| 831 | + | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 815 | 832 | community_name: thread_data.community_name, | |
| 816 | 833 | community_slug: slug, | |
| 817 | 834 | category_name: thread_data.category_name, |
| @@ -376,15 +376,21 @@ async fn health() -> Json<serde_json::Value> { | |||
| 376 | 376 | ||
| 377 | 377 | #[tracing::instrument(skip_all)] | |
| 378 | 378 | async fn not_found_handler( | |
| 379 | + | axum::extract::State(state): axum::extract::State<AppState>, | |
| 379 | 380 | session: Session, | |
| 380 | 381 | MaybeUser(session_user): MaybeUser, | |
| 381 | 382 | ) -> impl IntoResponse { | |
| 382 | 383 | let csrf_token = Some(csrf::get_or_create_token(&session).await); | |
| 383 | 384 | let session_user = session_user.map(|u| TemplateSessionUser { | |
| 385 | + | is_platform_admin: state.config.platform_admin_id == Some(u.user_id), | |
| 384 | 386 | username: u.username, | |
| 385 | 387 | }); | |
| 386 | 388 | ( | |
| 387 | 389 | StatusCode::NOT_FOUND, | |
| 388 | - | Error404Template { csrf_token, session_user }, | |
| 390 | + | Error404Template { | |
| 391 | + | csrf_token, | |
| 392 | + | session_user, | |
| 393 | + | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 394 | + | }, | |
| 389 | 395 | ) | |
| 390 | 396 | } |
| @@ -172,7 +172,11 @@ pub(super) async fn moderation_page( | |||
| 172 | 172 | ||
| 173 | 173 | Ok(ModerationTemplate { | |
| 174 | 174 | csrf_token, | |
| 175 | - | session_user: Some(TemplateSessionUser { username: user.username }), | |
| 175 | + | session_user: Some(TemplateSessionUser { | |
| 176 | + | is_platform_admin: state.config.platform_admin_id == Some(user.user_id), | |
| 177 | + | username: user.username, | |
| 178 | + | }), | |
| 179 | + | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 176 | 180 | community_name: community.name, | |
| 177 | 181 | community_slug: slug, | |
| 178 | 182 | bans, | |
| @@ -440,7 +444,11 @@ pub(super) async fn mod_log_page( | |||
| 440 | 444 | ||
| 441 | 445 | Ok(ModLogTemplate { | |
| 442 | 446 | csrf_token, | |
| 443 | - | session_user: Some(TemplateSessionUser { username: user.username }), | |
| 447 | + | session_user: Some(TemplateSessionUser { | |
| 448 | + | is_platform_admin: state.config.platform_admin_id == Some(user.user_id), | |
| 449 | + | username: user.username, | |
| 450 | + | }), | |
| 451 | + | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 444 | 452 | community_name: community.name, | |
| 445 | 453 | community_slug: slug, | |
| 446 | 454 | entries, |
| @@ -60,7 +60,11 @@ pub(super) async fn community_settings( | |||
| 60 | 60 | ||
| 61 | 61 | Ok(CommunitySettingsTemplate { | |
| 62 | 62 | csrf_token, | |
| 63 | - | session_user: Some(TemplateSessionUser { username: user.username }), | |
| 63 | + | session_user: Some(TemplateSessionUser { | |
| 64 | + | is_platform_admin: state.config.platform_admin_id == Some(user.user_id), | |
| 65 | + | username: user.username, | |
| 66 | + | }), | |
| 67 | + | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 64 | 68 | community_name: community.name, | |
| 65 | 69 | community_slug: slug, | |
| 66 | 70 | community_description: community.description, | |
| @@ -198,7 +202,11 @@ pub(super) async fn edit_category_form( | |||
| 198 | 202 | ||
| 199 | 203 | Ok(EditCategoryTemplate { | |
| 200 | 204 | csrf_token, | |
| 201 | - | session_user: Some(TemplateSessionUser { username: user.username }), | |
| 205 | + | session_user: Some(TemplateSessionUser { | |
| 206 | + | is_platform_admin: state.config.platform_admin_id == Some(user.user_id), | |
| 207 | + | username: user.username, | |
| 208 | + | }), | |
| 209 | + | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 202 | 210 | community_name: community.name, | |
| 203 | 211 | community_slug: slug, | |
| 204 | 212 | category_id: cat_id_str, |
| @@ -15,36 +15,13 @@ pub async fn run(pool: &PgPool) { | |||
| 15 | 15 | return; | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | - | // Admin user (placeholder MNW account ID) | |
| 19 | - | let admin_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); | |
| 20 | - | sqlx::query( | |
| 21 | - | "INSERT INTO users (mnw_account_id, username, display_name) | |
| 22 | - | VALUES ($1, $2, $3) | |
| 23 | - | ON CONFLICT (mnw_account_id) DO NOTHING", | |
| 24 | - | ) | |
| 25 | - | .bind(admin_id) | |
| 26 | - | .bind("admin") | |
| 27 | - | .bind("Admin") | |
| 28 | - | .execute(pool) | |
| 29 | - | .await | |
| 30 | - | .expect("failed to seed admin user"); | |
| 18 | + | // ── Users ────────────────────────────────────────────────────────── | |
| 31 | 19 | ||
| 32 | - | // Demo user | |
| 33 | - | let demo_id = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(); | |
| 34 | - | sqlx::query( | |
| 35 | - | "INSERT INTO users (mnw_account_id, username, display_name) | |
| 36 | - | VALUES ($1, $2, $3) | |
| 37 | - | ON CONFLICT (mnw_account_id) DO NOTHING", | |
| 38 | - | ) | |
| 39 | - | .bind(demo_id) | |
| 40 | - | .bind("maxmj") | |
| 41 | - | .bind("Max") | |
| 42 | - | .execute(pool) | |
| 43 | - | .await | |
| 44 | - | .expect("failed to seed demo user"); | |
| 20 | + | let users = seed_users(pool).await; | |
| 45 | 21 | ||
| 46 | - | // Community: Rust Programming | |
| 47 | - | let rust_community_id = seed_community( | |
| 22 | + | // ── Communities ──────────────────────────────────────────────────── | |
| 23 | + | ||
| 24 | + | let rust_id = seed_community( | |
| 48 | 25 | pool, | |
| 49 | 26 | "Rust Programming", | |
| 50 | 27 | "rust", | |
| @@ -52,18 +29,7 @@ pub async fn run(pool: &PgPool) { | |||
| 52 | 29 | ) | |
| 53 | 30 | .await; | |
| 54 | 31 | ||
| 55 | - | // Categories | |
| 56 | - | let general_id = | |
| 57 | - | seed_category(pool, rust_community_id, "General", "general", 0, Some("Anything that doesn't fit elsewhere.")).await; | |
| 58 | - | let help_id = | |
| 59 | - | seed_category(pool, rust_community_id, "Help & Questions", "help", 1, Some("Ask for help, get answers.")).await; | |
| 60 | - | let _show_id = | |
| 61 | - | seed_category(pool, rust_community_id, "Show & Tell", "show", 2, Some("Share what you've built.")).await; | |
| 62 | - | let _meta_id = | |
| 63 | - | seed_category(pool, rust_community_id, "Meta", "meta", 3, Some("Discussion about this community itself.")).await; | |
| 64 | - | ||
| 65 | - | // Community: Music Production | |
| 66 | - | let music_community_id = seed_community( | |
| 32 | + | let music_id = seed_community( | |
| 67 | 33 | pool, | |
| 68 | 34 | "Music Production", | |
| 69 | 35 | "music", | |
| @@ -71,12 +37,7 @@ pub async fn run(pool: &PgPool) { | |||
| 71 | 37 | ) | |
| 72 | 38 | .await; | |
| 73 | 39 | ||
| 74 | - | seed_category(pool, music_community_id, "General", "general", 0, Some("General music production discussion.")).await; | |
| 75 | - | seed_category(pool, music_community_id, "Mixing & Mastering", "mixing", 1, Some("Techniques for getting a polished mix.")).await; | |
| 76 | - | seed_category(pool, music_community_id, "Sound Design", "sound-design", 2, Some("Synthesis, sampling, and sound creation.")).await; | |
| 77 | - | ||
| 78 | - | // Community: Self-Hosted | |
| 79 | - | let selfhosted_community_id = seed_community( | |
| 40 | + | let selfhosted_id = seed_community( | |
| 80 | 41 | pool, | |
| 81 | 42 | "Self-Hosted", | |
| 82 | 43 | "selfhosted", | |
| @@ -84,58 +45,852 @@ pub async fn run(pool: &PgPool) { | |||
| 84 | 45 | ) | |
| 85 | 46 | .await; | |
| 86 | 47 | ||
| 87 | - | seed_category(pool, selfhosted_community_id, "General", "general", 0, Some("General self-hosting discussion.")).await; | |
| 88 | - | seed_category(pool, selfhosted_community_id, "Homelab", "homelab", 1, Some("Hardware, networking, and home server setups.")).await; | |
| 48 | + | // ── Categories ───────────────────────────────────────────────────── | |
| 89 | 49 | ||
| 90 | - | // Memberships (admin owns all communities) | |
| 91 | - | seed_membership(pool, admin_id, rust_community_id, "owner").await; | |
| 92 | - | seed_membership(pool, admin_id, music_community_id, "owner").await; | |
| 93 | - | seed_membership(pool, admin_id, selfhosted_community_id, "owner").await; | |
| 94 | - | seed_membership(pool, demo_id, rust_community_id, "member").await; | |
| 50 | + | // Rust | |
| 51 | + | let rust_general = seed_category(pool, rust_id, "General", "general", 0, Some("Anything that doesn't fit elsewhere.")).await; | |
| 52 | + | let rust_help = seed_category(pool, rust_id, "Help & Questions", "help", 1, Some("Ask for help, get answers.")).await; | |
| 53 | + | let _rust_show = seed_category(pool, rust_id, "Show & Tell", "show", 2, Some("Share what you've built.")).await; | |
| 54 | + | let _rust_meta = seed_category(pool, rust_id, "Meta", "meta", 3, Some("Discussion about this community itself.")).await; | |
| 95 | 55 | ||
| 96 | - | // Sample threads + posts in Rust/General | |
| 97 | - | let welcome_thread_id = seed_thread(pool, general_id, admin_id, "Welcome — read before posting", true, false).await; | |
| 98 | - | seed_post( | |
| 99 | - | pool, | |
| 100 | - | welcome_thread_id, | |
| 101 | - | admin_id, | |
| 102 | - | "Welcome to the Rust Programming community. Please be respectful, stay on topic, and use code blocks for code snippets.", | |
| 103 | - | None, | |
| 104 | - | ) | |
| 105 | - | .await; | |
| 56 | + | // Music — the main test community | |
| 57 | + | let music_general = seed_category(pool, music_id, "General", "general", 0, Some("General music production discussion.")).await; | |
| 58 | + | let music_mixing = seed_category(pool, music_id, "Mixing & Mastering", "mixing", 1, Some("Techniques for getting a polished mix.")).await; | |
| 59 | + | let music_sound = seed_category(pool, music_id, "Sound Design", "sound-design", 2, Some("Synthesis, sampling, and sound creation.")).await; | |
| 106 | 60 | ||
| 107 | - | let async_thread_id = seed_thread(pool, general_id, demo_id, "How do I get started with async Rust?", false, false).await; | |
| 108 | - | let op_post_id = seed_post( | |
| 109 | - | pool, | |
| 110 | - | async_thread_id, | |
| 111 | - | demo_id, | |
| 112 | - | "I've been writing synchronous Rust for a few months and want to start using async/await. What runtime should I pick? Is tokio the only option?\n\nAny recommended tutorials or blog posts would be great.", | |
| 113 | - | None, | |
| 114 | - | ) | |
| 115 | - | .await; | |
| 116 | - | seed_post( | |
| 117 | - | pool, | |
| 118 | - | async_thread_id, | |
| 119 | - | admin_id, | |
| 120 | - | "Tokio is the most popular and what most web frameworks (Axum, Actix) use. There's also `async-std` and `smol`, but the ecosystem gravitates toward tokio.\n\nStart with the tokio tutorial: it covers spawning tasks, channels, and I/O.", | |
| 121 | - | Some(op_post_id), | |
| 122 | - | ) | |
| 123 | - | .await; | |
| 61 | + | // Self-Hosted | |
| 62 | + | let sh_general = seed_category(pool, selfhosted_id, "General", "general", 0, Some("General self-hosting discussion.")).await; | |
| 63 | + | let _sh_homelab = seed_category(pool, selfhosted_id, "Homelab", "homelab", 1, Some("Hardware, networking, and home server setups.")).await; | |
| 124 | 64 | ||
| 125 | - | // Sample thread in Rust/Help | |
| 126 | - | let error_thread_id = seed_thread(pool, help_id, demo_id, "Best practices for error handling in Axum", false, false).await; | |
| 127 | - | seed_post( | |
| 128 | - | pool, | |
| 129 | - | error_thread_id, | |
| 130 | - | demo_id, | |
| 131 | - | "What's the recommended way to handle errors in Axum handlers? Should I use `anyhow`, `thiserror`, or something else? I keep writing `.map_err(|e| ...)` everywhere.", | |
| 132 | - | None, | |
| 133 | - | ) | |
| 134 | - | .await; | |
| 65 | + | // ── Memberships ──────────────────────────────────────────────────── | |
| 66 | + | ||
| 67 | + | for user in &users { | |
| 68 | + | seed_membership(pool, user.id, music_id, "member").await; | |
| 69 | + | } | |
| 70 | + | // admin owns all, maxmj is moderator of music | |
| 71 | + | seed_membership_upsert(pool, users[0].id, rust_id, "owner").await; | |
| 72 | + | seed_membership_upsert(pool, users[0].id, music_id, "owner").await; | |
| 73 | + | seed_membership_upsert(pool, users[0].id, selfhosted_id, "owner").await; | |
| 74 | + | seed_membership_upsert(pool, users[1].id, rust_id, "member").await; | |
| 75 | + | seed_membership_upsert(pool, users[1].id, music_id, "moderator").await; | |
| 76 | + | ||
| 77 | + | // ── Rust community: a few threads (unchanged from before) ────────── | |
| 78 | + | ||
| 79 | + | let welcome_id = seed_thread(pool, rust_general, users[0].id, "Welcome — read before posting", true, false).await; | |
| 80 | + | seed_post(pool, welcome_id, users[0].id, "Welcome to the Rust Programming community. Please be respectful, stay on topic, and use code blocks for code snippets.").await; | |
| 81 | + | ||
| 82 | + | let async_id = seed_thread(pool, rust_general, users[1].id, "How do I get started with async Rust?", false, false).await; | |
| 83 | + | seed_post(pool, async_id, users[1].id, "I've been writing synchronous Rust for a few months and want to start using async/await. What runtime should I pick? Is tokio the only option?\n\nAny recommended tutorials or blog posts would be great.").await; | |
| 84 | + | seed_post(pool, async_id, users[0].id, "Tokio is the most popular and what most web frameworks (Axum, Actix) use. There's also `async-std` and `smol`, but the ecosystem gravitates toward tokio.\n\nStart with the tokio tutorial: it covers spawning tasks, channels, and I/O.").await; | |
| 85 | + | ||
| 86 | + | let error_id = seed_thread(pool, rust_help, users[1].id, "Best practices for error handling in Axum", false, false).await; | |
| 87 | + | seed_post(pool, error_id, users[1].id, "What's the recommended way to handle errors in Axum handlers? Should I use `anyhow`, `thiserror`, or something else? I keep writing `.map_err(|e| ...)` everywhere.").await; | |
| 135 | 88 | ||
| 136 | - | tracing::info!("seeded 3 communities, 9 categories, 3 threads, 4 posts"); | |
| 89 | + | // ── Self-Hosted: a few threads ───────────────────────────────────── | |
| 90 | + | ||
| 91 | + | let caddy_id = seed_thread(pool, sh_general, users[0].id, "Caddy vs nginx for reverse proxy", false, false).await; | |
| 92 | + | seed_post(pool, caddy_id, users[0].id, "I have been using nginx for years but Caddy's automatic HTTPS is tempting. Anyone made the switch? What are the tradeoffs?").await; | |
| 93 | + | seed_post(pool, caddy_id, users[3].id, "Switched last year. Caddy's config is so much simpler. Automatic cert renewal is great. Only downside is slightly higher memory usage but it is negligible for small setups.").await; | |
| 94 | + | ||
| 95 | + | // ── Music community: bulk seed ───────────────────────────────────── | |
| 96 | + | ||
| 97 | + | seed_music_general(pool, music_general, &users).await; | |
| 98 | + | seed_music_mixing(pool, music_mixing, &users).await; | |
| 99 | + | seed_music_sound_design(pool, music_sound, &users).await; | |
| 100 | + | ||
| 101 | + | let total_threads: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM threads") | |
| 102 | + | .fetch_one(pool) | |
| 103 | + | .await | |
| 104 | + | .unwrap_or(0); | |
| 105 | + | let total_posts: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM posts") | |
| 106 | + | .fetch_one(pool) | |
| 107 | + | .await | |
| 108 | + | .unwrap_or(0); | |
| 109 | + | ||
| 110 | + | tracing::info!( | |
| 111 | + | "seeded 3 communities, {} users, {} threads, {} posts", | |
| 112 | + | users.len(), | |
| 113 | + | total_threads, | |
| 114 | + | total_posts | |
| 115 | + | ); | |
| 137 | 116 | } | |
| 138 | 117 | ||
| 118 | + | struct SeedUser { | |
| 119 | + | id: Uuid, | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | async fn seed_users(pool: &PgPool) -> Vec<SeedUser> { | |
| 123 | + | let user_data = [ | |
| 124 | + | ("00000000-0000-0000-0000-000000000001", "admin", "Admin"), | |
| 125 | + | ("00000000-0000-0000-0000-000000000002", "maxmj", "Max"), | |
| 126 | + | ("00000000-0000-0000-0000-000000000003", "synthwave99", "Juno"), | |
| 127 | + | ("00000000-0000-0000-0000-000000000004", "basshunter", "Erik"), | |
| 128 | + | ("00000000-0000-0000-0000-000000000005", "tape_hiss", "Rae"), | |
| 129 | + | ("00000000-0000-0000-0000-000000000006", "drumroom", "Cole"), | |
| 130 | + | ("00000000-0000-0000-0000-000000000007", "patchwork", "Lina"), | |
| 131 | + | ("00000000-0000-0000-0000-000000000008", "detuned", "Kai"), | |
| 132 | + | ("00000000-0000-0000-0000-000000000009", "resampled", "Noor"), | |
| 133 | + | ("00000000-0000-0000-0000-00000000000a", "clipgain", "Wren"), | |
| 134 | + | ]; | |
| 135 | + | ||
| 136 | + | let mut users = Vec::new(); | |
| 137 | + | for (uuid_str, username, display_name) in &user_data { | |
| 138 | + | let id = Uuid::parse_str(uuid_str).unwrap(); | |
| 139 | + | sqlx::query( | |
| 140 | + | "INSERT INTO users (mnw_account_id, username, display_name) | |
| 141 | + | VALUES ($1, $2, $3) | |
| 142 | + | ON CONFLICT (mnw_account_id) DO NOTHING", | |
| 143 | + | ) | |
| 144 | + | .bind(id) | |
| 145 | + | .bind(username) | |
| 146 | + | .bind(display_name) | |
| 147 | + | .execute(pool) | |
| 148 | + | .await | |
| 149 | + | .expect("failed to seed user"); | |
| 150 | + | users.push(SeedUser { id }); | |
| 151 | + | } | |
| 152 | + | users | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// 35 threads in Music/General — enough for 2 pages. | |
| 156 | + | async fn seed_music_general(pool: &PgPool, category_id: Uuid, users: &[SeedUser]) { | |
| 157 | + | let threads: &[(&str, &str, &[&str])] = &[ | |
| 158 | + | ( | |
| 159 | + | "Welcome to Music Production", | |
| 160 | + | "Ground rules: be kind, share knowledge, no self-promo spam. Use the right category for your topic.", | |
| 161 | + | &["Glad to be here.", "Thanks for setting this up."], | |
| 162 | + | ), | |
| 163 | + | ( | |
| 164 | + | "What DAW are you using in 2026?", | |
| 165 | + | "Curious what everyone is running these days. I have been on Ableton for years but keep hearing about Bitwig.", | |
| 166 | + | &[ | |
| 167 | + | "Reaper. Lightweight, cheap, endlessly customizable.", | |
| 168 | + | "Bitwig since version 4. The modulation system is unmatched.", | |
| 169 | + | "FL Studio. Started on it when I was 14, never left.", | |
| 170 | + | "Ableton but I keep a Reaper install for editing and batch processing.", | |
| 171 | + | "Logic. It just works and the stock plugins are surprisingly good.", | |
| 172 | + | ], | |
| 173 | + | ), | |
| 174 | + | ( | |
| 175 | + | "Favorite free plugins?", | |
| 176 | + | "What are your go-to free VSTs? Looking for synths, effects, anything.", | |
| 177 | + | &[ | |
| 178 | + | "Vital is the obvious answer. Best free synth by a mile.", | |
| 179 | + | "Valhalla Supermassive for reverb/delay. Sounds incredible for free.", | |
| 180 | + | "TDR Nova for EQ. Clean, surgical, zero CPU.", | |
| 181 | + | "Dexed if you want FM synthesis. Basically a free DX7.", | |
| 182 | + | ], | |
| 183 | + | ), | |
| 184 | + | ( | |
| 185 | + | "How do you organize your sample library?", | |
| 186 | + | "My sample folder is a disaster. Thousands of files with no structure. How do you keep things findable?", | |
| 187 | + | &[ | |
| 188 | + | "Genre > Type > Character. So like Electronic > Kicks > Punchy. Took a weekend to sort but worth it.", | |
| 189 | + | "I use tags instead of folders. AudioFiles has been great for this actually.", | |
| 190 | + | "Honestly I just search. If the filename is descriptive enough, search wins over folders every time.", | |
| 191 | + | ], | |
| 192 | + | ), | |
| 193 | + | ( | |
| 194 | + | "Analog vs digital debate in 2026", | |
| 195 | + | "Is there still a meaningful difference? Modern plugins are so good that I genuinely cannot tell in blind tests anymore.", | |
| 196 | + | &[ | |
| 197 | + | "The difference is workflow, not sound. Hardware forces commitment.", | |
| 198 | + | "I sold all my hardware last year. Zero regrets. Plugins are there.", | |
| 199 | + | "The tactile experience matters. Turning a real knob is different from clicking a virtual one.", | |
| 200 | + | "Both. I track through analog preamps and process in the box.", | |
| 201 | + | ], | |
| 202 | + | ), | |
| 203 | + | ( | |
| 204 | + | "How long did it take you to finish your first track?", | |
| 205 | + | "Been producing for 3 months and I cannot seem to finish anything. Is this normal?", | |
| 206 | + | &[ | |
| 207 | + | "Totally normal. Took me a year to finish something I was not embarrassed by.", | |
| 208 | + | "Force yourself to finish bad tracks. The skill of finishing is separate from the skill of producing.", | |
| 209 | + | "Set a deadline. 48 hours from start to bounce. Quality does not matter, completion does.", | |
| 210 | + | "Still working on my first one honestly.", | |
| 211 | + | "About 6 months. And it was terrible. But I finished it and that mattered.", | |
| 212 | + | ], | |
| 213 | + | ), | |
| 214 | + | ( | |
| 215 | + | "Studio monitors vs headphones for mixing?", | |
| 216 | + | "I live in an apartment and cannot treat the room properly. Should I just mix on headphones?", | |
| 217 | + | &[ | |
| 218 | + | "Headphones are fine if you know their character. Use a reference plugin like Sonarworks.", | |
| 219 | + | "I mix on DT 770s and reference on my car stereo. Not ideal but it works.", | |
| 220 | + | "Even cheap acoustic treatment helps. Hang some moving blankets.", | |
| 221 | + | ], | |
| 222 | + | ), | |
| 223 | + | ( | |
| 224 | + | "What's your creative process?", | |
| 225 | + | "Do you start with drums, melody, a sample? Curious how other people approach a blank session.", | |
| 226 | + | &[ | |
| 227 | + | "Always start with chords. Everything else follows from the harmony.", | |
| 228 | + | "I jam on a synth until something clicks, then build around that loop.", | |
| 229 | + | "Sound design first. Find an interesting texture, then write something that serves it.", | |
| 230 | + | "Drums. Always drums. Get the groove right and the rest writes itself.", | |
| 231 | + | "I hum melodies into my phone all day, then sit down and try to recreate them.", | |
| 232 | + | "Start with a reference track. Analyze the arrangement, then write something inspired by the structure.", | |
| 233 | + | ], | |
| 234 | + | ), | |
| 235 | + | ( | |
| 236 | + | "Best MIDI controllers under $200?", | |
| 237 | + | "Looking for something with keys and some knobs. Not a full workstation, just something to play ideas into the DAW.", | |
| 238 | + | &[ | |
| 239 | + | "Arturia Keystep 37. Great keybed for the price, plus the arpeggiator and sequencer.", | |
| 240 | + | "Novation Launchkey MK3. Deep DAW integration if you use Ableton.", | |
| 241 | + | "Akai MPK Mini. Tiny but surprisingly capable.", | |
| 242 | + | ], | |
| 243 | + | ), | |
| 244 | + | ( | |
| 245 | + | "CPU overload — how do you deal with it?", | |
| 246 | + | "My sessions keep hitting 100% CPU. Running a Ryzen 5 3600 with 16GB RAM. Is it time to upgrade or am I doing something wrong?", | |
| 247 | + | &[ | |
| 248 | + | "Freeze tracks you are not actively working on. Most DAWs support this.", | |
| 249 | + | "Bounce MIDI to audio once you are happy with the sound. Frees up the plugin.", | |
| 250 | + | "Check your buffer size. 256 for recording, 1024+ for mixing.", | |
| 251 | + | "Your CPU is fine. It is probably one plugin eating everything. Check per-plugin CPU.", | |
| 252 | + | ], | |
| 253 | + | ), | |
| 254 | + | ( | |
| 255 | + | "Music theory: how much do you actually use?", | |
| 256 | + | "I know basic chords and scales but never studied theory formally. How much do you all actually apply?", | |
| 257 | + | &[ | |
| 258 | + | "Enough to communicate with other musicians. Circle of fifths, chord functions, basic voice leading.", | |
| 259 | + | "Almost none. I go by ear and fix what sounds wrong.", | |
| 260 | + | "A lot. Understanding why something sounds good helps you recreate it faster.", | |
| 261 | + | ], | |
| 262 | + | ), | |
| 263 | + | ( | |
| 264 | + | "Side-chaining techniques", | |
| 265 | + | "What is your preferred method for side-chaining? Compressor? Volume automation? LFO tool?", | |
| 266 | + | &[ | |
| 267 | + | "Trackspacer. Not technically side-chain compression but solves the same problem better.", | |
| 268 | + | "Volume shaper plugin. More precise than a compressor and you can draw the exact curve.", | |
| 269 | + | "Classic compressor side-chain. Simple, works, everyone knows how to do it.", | |
| 270 | + | "I automate the volume manually. Full control, no artifacts.", | |
| 271 | + | ], | |
| 272 | + | ), | |
| 273 | + | ( | |
| 274 | + | "How do you avoid ear fatigue?", | |
| 275 | + | "I can mix for maybe 2 hours before everything starts sounding the same. Any tips?", | |
| 276 | + | &[ | |
| 277 | + | "Take breaks every 45 minutes. Walk around, drink water, reset.", | |
| 278 | + | "Mix at low volume. If it sounds good quiet, it will sound good loud.", | |
| 279 | + | "Reference constantly. Switch to a commercial track every 15 minutes.", | |
| 280 | + | ], | |
| 281 | + | ), | |
| 282 | + | ( | |
| 283 | + | "Collaboration: how do you share projects?", | |
| 284 | + | "Want to collaborate with a friend who uses a different DAW. What is the best approach?", | |
| 285 | + | &[ | |
| 286 | + | "Stems. Bounce each track as a WAV, share via cloud storage.", | |
| 287 | + | "MIDI + stems for the parts that need to be editable.", | |
| 288 | + | "We just use the same DAW. Easier than any workaround.", | |
| 289 | + | ], | |
| 290 | + | ), | |
| 291 | + | ( | |
| 292 | + | "Loudness standards for streaming platforms", | |
| 293 | + | "Spotify targets -14 LUFS, Apple Music -16. Should I master to a specific target or just make it sound good?", | |
| 294 | + | &[ | |
| 295 | + | "Master to what sounds best, then check loudness after. Most platforms normalize anyway.", | |
| 296 | + | "I aim for -12 to -14. Leaves headroom but still competitive.", | |
| 297 | + | "The number matters less than the dynamic range. A dynamic -14 sounds better than a squashed -8.", | |
| 298 | + | ], | |
| 299 | + | ), | |
| 300 | + | ( | |
| 301 | + | "What headphones do you use?", | |
| 302 | + | "Looking for mixing headphones. Budget around $150-300.", | |
| 303 | + | &[ | |
| 304 | + | "Sennheiser HD 600. Flat, detailed, comfortable for long sessions.", | |
| 305 | + | "Beyerdynamic DT 990 Pro. Wide soundstage. A bit bright but you learn the character.", | |
| 306 | + | "AKG K712. Open-back, great imaging.", | |
| 307 | + | "Audio-Technica ATH-M50x. Closed-back, good isolation, slightly hyped low end.", | |
| 308 | + | ], | |
| 309 | + | ), | |
| 310 | + | ( | |
| 311 | + | "Sampling ethics", | |
| 312 | + | "Where do you draw the line with sampling? Royalty-free packs? Vinyl? Other artists' released music?", | |
| 313 | + | &[ | |
| 314 | + | "Royalty-free is fair game obviously. For anything else, clear it or make it unrecognizable.", | |
| 315 | + | "I only sample stuff I have created myself or bought a license for.", | |
| 316 | + | "Sampling is an art form. The key is transformation.", | |
| 317 | + | ], | |
| 318 | + | ), | |
| 319 | + | ( | |
| 320 | + | "When to call a mix done?", | |
| 321 | + | "I keep tweaking endlessly. How do you know when to stop?", | |
| 322 | + | &[ | |
| 323 | + | "When changes become lateral instead of improvements. If you are just going back and forth, stop.", | |
| 324 | + | "Set a deadline. Ship it. You can always do a v2 later.", | |
| 325 | + | "Compare to your reference. If it holds up, it is done.", | |
| 326 | + | "When you start breaking things that were already working.", | |
| 327 | + | ], | |
| 328 | + | ), | |
| 329 | + | ( | |
| 330 | + | "Learning synthesis from scratch", | |
| 331 | + | "I have been using presets forever. Want to learn to design my own sounds. Where do I start?", | |
| 332 | + | &[ | |
| 333 | + | "Start with subtractive synthesis. One oscillator, one filter, one envelope. Understand what each does.", | |
| 334 | + | "Syntorial. It is a paid app but it teaches synthesis through ear training, not just theory.", | |
| 335 | + | "Pick one synth and learn it deeply. Vital or Serum are good because they visualize everything.", | |
| 336 | + | ], | |
| 337 | + | ), | |
| 338 | + | ( | |
| 339 | + | "Vocal processing chain", | |
| 340 | + | "What is your typical vocal chain? EQ, compression, de-essing, etc.", | |
| 341 | + | &[ | |
| 342 | + | "Gain staging, subtractive EQ, compressor (light 2:1), de-esser, additive EQ, reverb send.", | |
| 343 | + | "Similar but I add a saturation plugin before the compressor. Gives warmth.", | |
| 344 | + | "Depends entirely on the vocal and the genre. There is no universal chain.", | |
| 345 | + | ], | |
| 346 | + | ), | |
| 347 | + | ( | |
| 348 | + | "Budget acoustic treatment", | |
| 349 | + | "Can I treat a small room for under $200? What should I prioritize?", | |
| 350 | + | &[ | |
| 351 | + | "First reflections. Put absorption panels at mirror points on side walls.", | |
| 352 | + | "Bass traps in the corners are the highest impact single thing you can do.", | |
| 353 | + | "Rockwool panels in wooden frames. DIY for about $50 per panel.", | |
| 354 | + | "A thick rug on the floor and heavy curtains help more than people think.", | |
| 355 | + | ], | |
| 356 | + | ), | |
| 357 | + | ( | |
| 358 | + | "Favorite reverb plugin?", | |
| 359 | + | "Looking for something versatile. Plates, halls, rooms, all in one.", | |
| 360 | + | &[ | |
| 361 | + | "Valhalla Room. $50, sounds amazing, low CPU.", | |
| 362 | + | "FabFilter Pro-R 2. The decay rate EQ is genius.", | |
| 363 | + | "Stock reverb in your DAW is probably fine for 90% of use cases.", | |
| 364 | + | ], | |
| 365 | + | ), | |
| 366 | + | ( | |
| 367 | + | "How important is mastering?", | |
| 368 | + | "Can I just slap a limiter on the master bus and call it a day?", | |
| 369 | + | &[ | |
| 370 | + | "For demos and personal releases, yes. For commercial releases, get it mastered.", | |
| 371 | + | "Mastering is about perspective. A second set of ears in a treated room catches things you miss.", | |
| 372 | + | "iZotope Ozone is good enough for DIY mastering if you learn what each module does.", | |
| 373 | + | ], | |
| 374 | + | ), | |
| 375 | + | ( | |
| 376 | + | "Managing multiple projects at once", | |
| 377 | + | "I have like 30 unfinished projects. How do you manage the backlog?", | |
| 378 | + | &[ | |
| 379 | + | "Pick three. Finish them. Delete the rest. Harsh but effective.", | |
| 380 | + | "I give each project a status: idea, in progress, mixing, done. Only 2 can be in progress at once.", | |
| 381 | + | "Set a monthly goal. One finished track per month, minimum.", | |
| 382 | + | ], | |
| 383 | + | ), | |
| 384 | + | ( | |
| 385 | + | "Track referencing workflow", | |
| 386 | + | "How do you reference against commercial tracks while mixing?", | |
| 387 | + | &[ | |
| 388 | + | "Import the reference directly into the session. Level match with a LUFS meter, A/B constantly.", | |
| 389 | + | "I use a dedicated plugin that lets me switch between my mix and references with one click.", | |
| 390 | + | "Listen to the reference on the same speakers, back to back. Do not overthink the workflow.", | |
| 391 | + | ], | |
| 392 | + | ), | |
| 393 | + | ( | |
| 394 | + | "Gain staging basics", | |
| 395 | + | "Keep hearing about gain staging but not sure I understand it. ELI5?", | |
| 396 | + | &[ | |
| 397 | + | "Every plugin in your chain should receive signal at a reasonable level. If you push hot into a compressor, it behaves differently than if you feed it -18dBFS.", | |
| 398 | + | "Basically: do not clip anything in the chain before the final limiter. Use trim/gain plugins if needed.", | |
| 399 | + | "Aim for peaks around -12 to -6 on each channel. Leaves headroom on the master.", | |
| 400 | + | ], | |
| 401 | + | ), | |
| 402 | + | ( | |
| 403 | + | "Lo-fi production tips", | |
| 404 | + | "Want to make lo-fi hip hop style beats. What gives that characteristic sound?", | |
| 405 | + | &[ | |
| 406 | + | "Bitcrushing, vinyl noise, tape saturation, de-tuned samples, swing on the drums.", | |
| 407 | + | "Record stuff through a cheap mic or tape deck. Real degradation sounds better than plugins simulating it.", | |
| 408 | + | "Side-chain the whole mix to the kick. Slow attack, slow release. That pumping effect is key.", | |
| 409 | + | "Use jazz chord voicings. Maj7, min9, dom13. That is the harmonic foundation.", | |
| 410 | + | ], | |
| 411 | + | ), | |
| 412 | + | ( | |
| 413 | + | "What's your backup strategy?", | |
| 414 | + | "Lost a project file last week and it hurt. How do you backup your work?", | |
| 415 | + | &[ | |
| 416 | + | "Git for project files, rsync to a NAS for samples and bounces.", | |
| 417 | + | "Time Machine plus a monthly clone to an external drive.", | |
| 418 | + | "Cloud sync. Dropbox for active projects, cold storage archive for finished work.", | |
| 419 | + | "Three copies: local SSD, NAS, off-site. If it does not exist in three places, it does not exist.", | |
| 420 | + | ], | |
| 421 | + | ), | |
| 422 | + | ( | |
| 423 | + | "Distortion as a creative tool", | |
| 424 | + | "Using distortion for more than just guitars. What are your favorite ways to use it?", | |
| 425 | + | &[ | |
| 426 | + | "Parallel saturation on vocals. Blend in just enough to add presence without obvious distortion.", |
Lines truncated
| @@ -11,6 +11,7 @@ use super::CsrfTokenOption; | |||
| 11 | 11 | /// Minimal user info for the site header. | |
| 12 | 12 | pub struct TemplateSessionUser { | |
| 13 | 13 | pub username: String, | |
| 14 | + | pub is_platform_admin: bool, | |
| 14 | 15 | } | |
| 15 | 16 | ||
| 16 | 17 | /// Row in the forum directory (home page). | |
| @@ -92,6 +93,7 @@ impl Pagination { | |||
| 92 | 93 | pub struct ForumDirectoryTemplate { | |
| 93 | 94 | pub csrf_token: CsrfTokenOption, | |
| 94 | 95 | pub session_user: Option<TemplateSessionUser>, | |
| 96 | + | pub mnw_base_url: String, | |
| 95 | 97 | pub communities: Vec<CommunityDirectoryRow>, | |
| 96 | 98 | } | |
| 97 | 99 | ||
| @@ -101,6 +103,7 @@ pub struct ForumDirectoryTemplate { | |||
| 101 | 103 | pub struct CommunityTemplate { | |
| 102 | 104 | pub csrf_token: CsrfTokenOption, | |
| 103 | 105 | pub session_user: Option<TemplateSessionUser>, | |
| 106 | + | pub mnw_base_url: String, | |
| 104 | 107 | pub community_name: String, | |
| 105 | 108 | pub community_slug: String, | |
| 106 | 109 | pub community_description: Option<String>, | |
| @@ -154,6 +157,7 @@ pub struct ThreadTemplate { | |||
| 154 | 157 | pub struct NewThreadTemplate { | |
| 155 | 158 | pub csrf_token: CsrfTokenOption, | |
| 156 | 159 | pub session_user: Option<TemplateSessionUser>, | |
| 160 | + | pub mnw_base_url: String, | |
| 157 | 161 | pub community_name: String, | |
| 158 | 162 | pub community_slug: String, | |
| 159 | 163 | pub category_name: String, | |
| @@ -166,6 +170,7 @@ pub struct NewThreadTemplate { | |||
| 166 | 170 | pub struct EditPostTemplate { | |
| 167 | 171 | pub csrf_token: CsrfTokenOption, | |
| 168 | 172 | pub session_user: Option<TemplateSessionUser>, | |
| 173 | + | pub mnw_base_url: String, | |
| 169 | 174 | pub community_name: String, | |
| 170 | 175 | pub community_slug: String, | |
| 171 | 176 | pub category_name: String, | |
| @@ -182,6 +187,7 @@ pub struct EditPostTemplate { | |||
| 182 | 187 | pub struct EditThreadTemplate { | |
| 183 | 188 | pub csrf_token: CsrfTokenOption, | |
| 184 | 189 | pub session_user: Option<TemplateSessionUser>, | |
| 190 | + | pub mnw_base_url: String, | |
| 185 | 191 | pub community_name: String, | |
| 186 | 192 | pub community_slug: String, | |
| 187 | 193 | pub category_name: String, | |
| @@ -207,6 +213,7 @@ pub struct SettingsCategoryRow { | |||
| 207 | 213 | pub struct CommunitySettingsTemplate { | |
| 208 | 214 | pub csrf_token: CsrfTokenOption, | |
| 209 | 215 | pub session_user: Option<TemplateSessionUser>, | |
| 216 | + | pub mnw_base_url: String, | |
| 210 | 217 | pub community_name: String, | |
| 211 | 218 | pub community_slug: String, | |
| 212 | 219 | pub community_description: Option<String>, | |
| @@ -219,6 +226,7 @@ pub struct CommunitySettingsTemplate { | |||
| 219 | 226 | pub struct EditCategoryTemplate { | |
| 220 | 227 | pub csrf_token: CsrfTokenOption, | |
| 221 | 228 | pub session_user: Option<TemplateSessionUser>, | |
| 229 | + | pub mnw_base_url: String, | |
| 222 | 230 | pub community_name: String, | |
| 223 | 231 | pub community_slug: String, | |
| 224 | 232 | pub category_id: String, | |
| @@ -252,6 +260,7 @@ pub struct MembersTemplate { | |||
| 252 | 260 | pub struct Error404Template { | |
| 253 | 261 | pub csrf_token: CsrfTokenOption, | |
| 254 | 262 | pub session_user: Option<TemplateSessionUser>, | |
| 263 | + | pub mnw_base_url: String, | |
| 255 | 264 | } | |
| 256 | 265 | ||
| 257 | 266 | /// 500 error page. | |
| @@ -260,6 +269,7 @@ pub struct Error404Template { | |||
| 260 | 269 | pub struct Error500Template { | |
| 261 | 270 | pub csrf_token: CsrfTokenOption, | |
| 262 | 271 | pub session_user: Option<TemplateSessionUser>, | |
| 272 | + | pub mnw_base_url: String, | |
| 263 | 273 | } | |
| 264 | 274 | ||
| 265 | 275 | // ============================================================================ | |
| @@ -292,6 +302,7 @@ pub struct ModLogRow { | |||
| 292 | 302 | pub struct ModerationTemplate { | |
| 293 | 303 | pub csrf_token: CsrfTokenOption, | |
| 294 | 304 | pub session_user: Option<TemplateSessionUser>, | |
| 305 | + | pub mnw_base_url: String, | |
| 295 | 306 | pub community_name: String, | |
| 296 | 307 | pub community_slug: String, | |
| 297 | 308 | pub bans: Vec<BanListRow>, | |
| @@ -304,6 +315,7 @@ pub struct ModerationTemplate { | |||
| 304 | 315 | pub struct ModLogTemplate { | |
| 305 | 316 | pub csrf_token: CsrfTokenOption, | |
| 306 | 317 | pub session_user: Option<TemplateSessionUser>, | |
| 318 | + | pub mnw_base_url: String, | |
| 307 | 319 | pub community_name: String, | |
| 308 | 320 | pub community_slug: String, | |
| 309 | 321 | pub entries: Vec<ModLogRow>, | |
| @@ -338,6 +350,7 @@ pub struct AdminUserViewRow { | |||
| 338 | 350 | pub struct AdminDashboardTemplate { | |
| 339 | 351 | pub csrf_token: CsrfTokenOption, | |
| 340 | 352 | pub session_user: Option<TemplateSessionUser>, | |
| 353 | + | pub mnw_base_url: String, | |
| 341 | 354 | pub communities: Vec<AdminCommunityViewRow>, | |
| 342 | 355 | pub users: Vec<AdminUserViewRow>, | |
| 343 | 356 | pub search_query: String, |
| @@ -94,8 +94,8 @@ h1 { | |||
| 94 | 94 | font-family: "Young Serif", serif; | |
| 95 | 95 | font-weight: normal; | |
| 96 | 96 | color: var(--detail); | |
| 97 | - | text-align: center; | |
| 98 | - | font-size: 2.5rem; | |
| 97 | + | text-align: left; | |
| 98 | + | font-size: 1.5rem; | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | 101 | h2, | |
| @@ -131,7 +131,7 @@ a:hover { | |||
| 131 | 131 | .container { | |
| 132 | 132 | max-width: 1200px; | |
| 133 | 133 | margin: 0 auto; | |
| 134 | - | padding: 1.25rem; | |
| 134 | + | padding: 1rem; | |
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | 137 | .padded-page { | |
| @@ -160,6 +160,18 @@ a:hover { | |||
| 160 | 160 | align-items: center; | |
| 161 | 161 | } | |
| 162 | 162 | ||
| 163 | + | .nav-links a { | |
| 164 | + | color: var(--detail); | |
| 165 | + | text-decoration: none; | |
| 166 | + | font-family: "IBM Plex Mono", monospace; | |
| 167 | + | transition: opacity 0.2s ease; | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | .nav-links a:hover { | |
| 171 | + | opacity: 0.6; | |
| 172 | + | text-decoration: none; | |
| 173 | + | } | |
| 174 | + | ||
| 163 | 175 | .site-logo { | |
| 164 | 176 | font-family: "Young Serif", serif; | |
| 165 | 177 | font-size: 1.25rem; | |
| @@ -172,11 +184,6 @@ a:hover { | |||
| 172 | 184 | text-decoration: none; | |
| 173 | 185 | } | |
| 174 | 186 | ||
| 175 | - | .nav-user { | |
| 176 | - | font-family: "IBM Plex Mono", monospace; | |
| 177 | - | font-size: 0.9rem; | |
| 178 | - | } | |
| 179 | - | ||
| 180 | 187 | .link-button { | |
| 181 | 188 | background: none; | |
| 182 | 189 | border: none; | |
| @@ -691,7 +698,7 @@ form button:hover { | |||
| 691 | 698 | } | |
| 692 | 699 | ||
| 693 | 700 | .post-item { | |
| 694 | - | padding: 1rem 1.25rem; | |
| 701 | + | padding: 0.75rem 1rem; | |
| 695 | 702 | border-bottom: 1px solid var(--border); | |
| 696 | 703 | } | |
| 697 | 704 | ||
| @@ -703,7 +710,7 @@ form button:hover { | |||
| 703 | 710 | display: flex; | |
| 704 | 711 | justify-content: space-between; | |
| 705 | 712 | align-items: baseline; | |
| 706 | - | margin-bottom: 0.5rem; | |
| 713 | + | margin-bottom: 0.25rem; | |
| 707 | 714 | } | |
| 708 | 715 | ||
| 709 | 716 | .post-author { | |
| @@ -728,7 +735,7 @@ form button:hover { | |||
| 728 | 735 | ||
| 729 | 736 | .post-body { | |
| 730 | 737 | font-size: 0.9rem; | |
| 731 | - | line-height: 1.6; | |
| 738 | + | line-height: 1.5; | |
| 732 | 739 | } | |
| 733 | 740 | ||
| 734 | 741 | .post-body p { | |
| @@ -808,68 +815,36 @@ form button:hover { | |||
| 808 | 815 | } | |
| 809 | 816 | ||
| 810 | 817 | /* =========================================== | |
| 811 | - | REPLY FORM | |
| 818 | + | DRAFT INDICATOR | |
| 812 | 819 | =========================================== */ | |
| 813 | 820 | ||
| 814 | - | .reply-section { | |
| 815 | - | padding: 1.25rem; | |
| 816 | - | margin-top: 0.75rem; | |
| 817 | - | } | |
| 818 | - | ||
| 819 | - | .reply-section h3 { | |
| 820 | - | margin-bottom: 0.75rem; | |
| 821 | - | } | |
| 822 | - | ||
| 823 | - | /* =========================================== | |
| 824 | - | CATEGORY TABLE (project view) | |
| 825 | - | =========================================== */ | |
| 826 | - | ||
| 827 | - | .category-table { | |
| 828 | - | width: 100%; | |
| 829 | - | border-collapse: collapse; | |
| 830 | - | font-size: 0.85rem; | |
| 831 | - | font-family: "Lato", sans-serif; | |
| 832 | - | } | |
| 833 | - | ||
| 834 | - | .category-table td { | |
| 835 | - | padding: 0.75rem 1rem; | |
| 836 | - | border-bottom: 1px solid var(--border); | |
| 837 | - | } | |
| 838 | - | ||
| 839 | - | .category-table tr { | |
| 840 | - | transition: background 0.1s ease; | |
| 841 | - | } | |
| 842 | - | ||
| 843 | - | .category-table tr:hover { | |
| 844 | - | background: var(--light-background); | |
| 821 | + | .draft-indicator { | |
| 822 | + | font-family: "IBM Plex Mono", monospace; | |
| 823 | + | font-size: 0.75rem; | |
| 824 | + | color: var(--text-muted); | |
| 825 | + | margin-bottom: 0.25rem; | |
| 845 | 826 | } | |
| 846 | 827 | ||
| 847 | - | .category-name { | |
| 848 | - | font-family: "Young Serif", serif; | |
| 849 | - | font-size: 1rem; | |
| 828 | + | .draft-discard { | |
| 829 | + | cursor: pointer; | |
| 830 | + | color: var(--text-muted); | |
| 850 | 831 | } | |
| 851 | 832 | ||
| 852 | - | .category-name a { | |
| 853 | - | color: var(--detail); | |
| 854 | - | text-decoration: none; | |
| 833 | + | .draft-discard:hover { | |
| 834 | + | color: var(--danger); | |
| 855 | 835 | } | |
| 856 | 836 | ||
| 857 | - | .category-name a:hover { | |
| 858 | - | text-decoration: underline; | |
| 859 | - | } | |
| 837 | + | /* =========================================== | |
| 838 | + | REPLY FORM | |
| 839 | + | =========================================== */ | |
| 860 | 840 | ||
| 861 | - | .category-description { | |
| 862 | - | font-size: 0.8rem; | |
| 863 | - | color: var(--text-muted); | |
| 864 | - | margin-top: 0.15rem; | |
| 841 | + | .reply-section { | |
| 842 | + | padding: 1rem; | |
| 843 | + | margin-top: 0.5rem; | |
| 865 | 844 | } | |
| 866 | 845 | ||
| 867 | - | .category-stats { | |
| 868 | - | text-align: right; | |
| 869 | - | white-space: nowrap; | |
| 870 | - | font-family: "IBM Plex Mono", monospace; | |
| 871 | - | font-size: 0.8rem; | |
| 872 | - | color: var(--text-muted); | |
| 846 | + | .reply-section h3 { | |
| 847 | + | margin-bottom: 0.75rem; | |
| 873 | 848 | } | |
| 874 | 849 | ||
| 875 | 850 | /* =========================================== | |
| @@ -880,12 +855,12 @@ form button:hover { | |||
| 880 | 855 | display: flex; | |
| 881 | 856 | justify-content: space-between; | |
| 882 | 857 | align-items: center; | |
| 883 | - | margin-bottom: 1rem; | |
| 858 | + | margin-bottom: 0.5rem; | |
| 884 | 859 | } | |
| 885 | 860 | ||
| 886 | 861 | .page-header h1 { | |
| 887 | 862 | text-align: left; | |
| 888 | - | font-size: 1.75rem; | |
| 863 | + | font-size: 1.5rem; | |
| 889 | 864 | margin: 0; | |
| 890 | 865 | } | |
| 891 | 866 | ||
| @@ -1125,7 +1100,7 @@ form button:hover { | |||
| 1125 | 1100 | ||
| 1126 | 1101 | .empty-state { | |
| 1127 | 1102 | text-align: center; | |
| 1128 | - | padding: 2rem 1rem; | |
| 1103 | + | padding: 1rem; | |
| 1129 | 1104 | color: var(--text-muted); | |
| 1130 | 1105 | font-family: "IBM Plex Mono", monospace; | |
| 1131 | 1106 | } | |
| @@ -1136,21 +1111,42 @@ form button:hover { | |||
| 1136 | 1111 | ||
| 1137 | 1112 | .site-footer { | |
| 1138 | 1113 | text-align: center; | |
| 1139 | - | padding: 2rem 1rem 1rem; | |
| 1140 | - | margin-top: 2rem; | |
| 1114 | + | padding: 1rem; | |
| 1115 | + | margin-top: 1rem; | |
| 1141 | 1116 | border-top: 1px solid var(--border); | |
| 1142 | 1117 | font-family: "IBM Plex Mono", monospace; | |
| 1143 | 1118 | font-size: 0.75rem; | |
| 1144 | 1119 | color: var(--text-muted); | |
| 1145 | 1120 | } | |
| 1146 | 1121 | ||
| 1122 | + | .site-footer a { | |
| 1123 | + | color: var(--detail); | |
| 1124 | + | text-decoration: none; | |
| 1125 | + | } | |
| 1126 | + | ||
| 1127 | + | .site-footer a:hover { | |
| 1128 | + | text-decoration: underline; | |
| 1129 | + | } | |
| 1130 | + | ||
| 1131 | + | .site-footer-links { | |
| 1132 | + | margin-bottom: 0.5rem; | |
| 1133 | + | } | |
| 1134 | + | ||
| 1135 | + | .site-footer-links a { | |
| 1136 | + | margin: 0 0.75rem; | |
| 1137 | + | } | |
| 1138 | + | ||
| 1139 | + | .footer-sep { | |
| 1140 | + | margin: 0 0.4rem; | |
| 1141 | + | } | |
| 1142 | + | ||
| 1147 | 1143 | /* =========================================== | |
| 1148 | 1144 | RESPONSIVE — 768px (tablet) | |
| 1149 | 1145 | =========================================== */ | |
| 1150 | 1146 | ||
| 1151 | 1147 | @media (max-width: 768px) { | |
| 1152 | 1148 | .container { | |
| 1153 | - | padding: 1rem; | |
| 1149 | + | padding: 0.75rem; | |
| 1154 | 1150 | } | |
| 1155 | 1151 | ||
| 1156 | 1152 | .padded-page { |
| @@ -31,6 +31,14 @@ | |||
| 31 | 31 | </main> | |
| 32 | 32 | ||
| 33 | 33 | <footer class="site-footer"> | |
| 34 | + | <div class="site-footer-links"> | |
| 35 | + | <a href="{{ mnw_base_url }}/pricing">Pricing</a> | |
| 36 | + | <a href="{{ mnw_base_url }}/creators">Creators</a> | |
| 37 | + | <a href="{{ mnw_base_url }}/docs">Docs</a> | |
| 38 | + | <a href="{{ mnw_base_url }}/policy">Policy</a> | |
| 39 | + | </div> | |
| 40 | + | <span>Powered by <a href="{{ mnw_base_url }}/">Makenot<span class="dot">.</span>work</a></span> | |
| 41 | + | <span class="footer-sep">·</span> | |
| 34 | 42 | <span>Report abuse: moderation@makenot.work</span> | |
| 35 | 43 | </footer> | |
| 36 | 44 | ||
| @@ -137,6 +145,76 @@ | |||
| 137 | 145 | } | |
| 138 | 146 | })(); | |
| 139 | 147 | </script> | |
| 148 | + | <script> | |
| 149 | + | (function() { | |
| 150 | + | var body = document.getElementById('body'); | |
| 151 | + | if (!body || body.tagName !== 'TEXTAREA') return; | |
| 152 | + | if (localStorage.getItem('mt_tracking_enabled') === 'false') return; | |
| 153 | + | ||
| 154 | + | var title = document.getElementById('title'); | |
| 155 | + | if (title && title.tagName !== 'INPUT') title = null; | |
| 156 | + | var key = 'mt_draft:' + window.location.pathname; | |
| 157 | + | var form = body.closest('form'); | |
| 158 | + | var timer = null; | |
| 159 | + | var MAX_DRAFTS = 20; | |
| 160 | + | var WEEK_MS = 7 * 24 * 60 * 60 * 1000; | |
| 161 | + | ||
| 162 | + | // Restore | |
| 163 | + | var raw = localStorage.getItem(key); | |
| 164 | + | if (raw && !body.value.trim()) { | |
| 165 | + | try { | |
| 166 | + | var draft = JSON.parse(raw); | |
| 167 | + | if (Date.now() - draft.ts > WEEK_MS) { localStorage.removeItem(key); } | |
| 168 | + | else { | |
| 169 | + | body.value = draft.body || ''; | |
| 170 | + | if (title && !title.value.trim()) title.value = draft.title || ''; | |
| 171 | + | var ind = document.createElement('div'); | |
| 172 | + | ind.className = 'draft-indicator'; | |
| 173 | + | ind.textContent = 'Draft restored. '; | |
| 174 | + | var discard = document.createElement('a'); | |
| 175 | + | discard.textContent = 'Discard'; | |
| 176 | + | discard.href = '#'; | |
| 177 | + | discard.className = 'draft-discard'; | |
| 178 | + | discard.onclick = function(e) { | |
| 179 | + | e.preventDefault(); | |
| 180 | + | localStorage.removeItem(key); | |
| 181 | + | body.value = ''; | |
| 182 | + | if (title) title.value = ''; | |
| 183 | + | ind.remove(); | |
| 184 | + | }; | |
| 185 | + | ind.appendChild(discard); | |
| 186 | + | body.parentNode.insertBefore(ind, body); | |
| 187 | + | } | |
| 188 | + | } catch(e) { localStorage.removeItem(key); } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // Save (debounced) | |
| 192 | + | function save() { | |
| 193 | + | var b = body.value.trim(); | |
| 194 | + | var t = title ? title.value.trim() : ''; | |
| 195 | + | if (!b && !t) { localStorage.removeItem(key); return; } | |
| 196 | + | localStorage.setItem(key, JSON.stringify({ body: body.value, title: title ? title.value : '', ts: Date.now() })); | |
| 197 | + | // LRU cleanup | |
| 198 | + | var drafts = []; | |
| 199 | + | for (var i = 0; i < localStorage.length; i++) { | |
| 200 | + | var k = localStorage.key(i); | |
| 201 | + | if (k && k.indexOf('mt_draft:') === 0 && k !== key) { | |
| 202 | + | try { drafts.push({ k: k, ts: JSON.parse(localStorage.getItem(k)).ts }); } catch(e) {} | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | if (drafts.length >= MAX_DRAFTS) { | |
| 206 | + | drafts.sort(function(a, b) { return a.ts - b.ts; }); | |
| 207 | + | while (drafts.length >= MAX_DRAFTS) { localStorage.removeItem(drafts.shift().k); } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | function debounced() { clearTimeout(timer); timer = setTimeout(save, 1000); } | |
| 211 | + | body.addEventListener('input', debounced); | |
| 212 | + | if (title) title.addEventListener('input', debounced); | |
| 213 | + | ||
| 214 | + | // Clear on submit | |
| 215 | + | if (form) form.addEventListener('submit', function() { localStorage.removeItem(key); }); | |
| 216 | + | })(); | |
| 217 | + | </script> | |
| 140 | 218 | {% block scripts %}{% endblock %} | |
| 141 | 219 | </body> | |
| 142 | 220 | </html> |
| @@ -26,24 +26,28 @@ | |||
| 26 | 26 | </span> | |
| 27 | 27 | </div> | |
| 28 | 28 | {% if let Some(desc) = community_description %} | |
| 29 | - | <p style="margin-bottom: 1.5rem; color: var(--text-muted);">{{ desc }}</p> | |
| 29 | + | <p style="margin-bottom: 0.5rem; color: var(--text-muted);">{{ desc }}</p> | |
| 30 | 30 | {% endif %} | |
| 31 | 31 | {% if categories.is_empty() %} | |
| 32 | 32 | <div class="empty-state">No categories yet.</div> | |
| 33 | 33 | {% else %} | |
| 34 | - | <table class="category-table"> | |
| 34 | + | <table class="directory-table"> | |
| 35 | + | <thead> | |
| 36 | + | <tr> | |
| 37 | + | <th>Category</th> | |
| 38 | + | <th class="col-items">Threads</th> | |
| 39 | + | </tr> | |
| 40 | + | </thead> | |
| 35 | 41 | <tbody> | |
| 36 | 42 | {% for cat in categories %} | |
| 37 | 43 | <tr> | |
| 38 | 44 | <td> | |
| 39 | - | <div class="category-name"><a href="/p/{{ community_slug }}/{{ cat.slug }}">{{ cat.name }}</a></div> | |
| 45 | + | <div class="directory-name"><a href="/p/{{ community_slug }}/{{ cat.slug }}">{{ cat.name }}</a></div> | |
| 40 | 46 | {% if let Some(desc) = cat.description %} | |
| 41 | - | <div class="category-description">{{ desc }}</div> | |
| 47 | + | <div class="directory-desc">{{ desc }}</div> | |
| 42 | 48 | {% endif %} | |
| 43 | 49 | </td> | |
| 44 | - | <td class="category-stats"> | |
| 45 | - | {{ cat.thread_count }} threads | |
| 46 | - | </td> | |
| 50 | + | <td class="col-items">{{ cat.thread_count }}</td> | |
| 47 | 51 | </tr> | |
| 48 | 52 | {% endfor %} | |
| 49 | 53 | </tbody> |
| @@ -8,11 +8,17 @@ | |||
| 8 | 8 | </label> | |
| 9 | 9 | <nav aria-label="Main navigation"> | |
| 10 | 10 | <div class="nav-links"> | |
| 11 | + | <a href="{{ mnw_base_url }}/">Library</a> | |
| 12 | + | <a href="{{ mnw_base_url }}/discover">Discover</a> | |
| 11 | 13 | {% if let Some(user) = session_user %} | |
| 12 | - | <span class="nav-user">{{ user.username }}</span> | |
| 14 | + | <a href="{{ mnw_base_url }}/feed">Feed</a> | |
| 15 | + | <a href="{{ mnw_base_url }}/u/{{ user.username }}">{{ user.username }}</a> | |
| 16 | + | <a href="{{ mnw_base_url }}/dashboard">Dashboard</a> | |
| 17 | + | {% if user.is_platform_admin %}<a href="/_admin">Admin</a>{% endif %} | |
| 13 | 18 | <a href="/auth/logout" class="link-button" aria-label="Log out">Log Out</a> | |
| 14 | 19 | {% else %} | |
| 15 | 20 | <a href="/auth/login">Login</a> | |
| 21 | + | <a href="{{ mnw_base_url }}/join">Join</a> | |
| 16 | 22 | {% endif %} | |
| 17 | 23 | </div> | |
| 18 | 24 | </nav> |
| @@ -242,9 +242,11 @@ async fn member_list_shows_members_with_roles() { | |||
| 242 | 242 | assert!(resp.text.contains("themod"), "mod should be listed"); | |
| 243 | 243 | assert!(resp.text.contains("regular"), "member should be listed"); | |
| 244 | 244 | // Owner should appear before member in the table (sorted by role) | |
| 245 | - | // Use /u/ links to avoid matching the header nav username | |
| 246 | - | let owner_pos = resp.text.find("/u/theowner").unwrap(); | |
| 247 | - | let member_pos = resp.text.find("/u/regular").unwrap(); | |
| 245 | + | // Search within the data-table to avoid matching the header nav Profile link | |
| 246 | + | let table_start = resp.text.find("data-table").unwrap(); | |
| 247 | + | let table_html = &resp.text[table_start..]; | |
| 248 | + | let owner_pos = table_html.find("/u/theowner").unwrap(); | |
| 249 | + | let member_pos = table_html.find("/u/regular").unwrap(); | |
| 248 | 250 | assert!( | |
| 249 | 251 | owner_pos < member_pos, | |
| 250 | 252 | "owner should appear before regular member" |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Multithreaded — Todo | |
| 2 | 2 | ||
| 3 | - | Done: Phases 0-11. 106 tests (65 integration + 25 unit lib + 16 unit mt-core). v0.2.0 deployed to astra (PLATFORM_ADMIN_ID set). Routes split into directory module (`routes/`). Graceful shutdown + reqwest timeouts. Unused deps removed. First formal audit: B+ (2026-03-14). All 10 audit findings resolved (1 HIGH + 4 MEDIUM + 5 SMALL). Rate limiting added (tower-governor, per-IP on write endpoints). Expired ban cleanup (opportunistic on moderation page view). Test coverage gaps closed (mute/unban/unmute handlers, category edit/reorder, expired ban behavior). Initial git commit done. | |
| 3 | + | Done: Phases 0-11. 106 tests (65 integration + 25 unit lib + 16 unit mt-core). v0.2.1. Routes split into directory module (`routes/`). Graceful shutdown + reqwest timeouts. Unused deps removed. First formal audit: B+ (2026-03-14). All 10 audit findings resolved (1 HIGH + 4 MEDIUM + 5 SMALL). Rate limiting (tower-governor). Expired ban cleanup (opportunistic). Test coverage gaps closed. Ammonia HTML sanitizer (defense-in-depth). Audit grade: A. Initial git commit done. UI aligned with MNW: header nav (Library, Discover, Feed, Profile, Dashboard link to MNW), footer ("Powered by Makenot.work"), nav link styling (IBM Plex Mono, opacity transitions), dead CSS removed. | |
| 4 | 4 | ||
| 5 | 5 | Completed work archived in [todo_done.md](todo_done.md). | |
| 6 | 6 | ||
| @@ -8,69 +8,290 @@ Alpha = forum is usable end-to-end: users can sign in via MNW OAuth, browse proj | |||
| 8 | 8 | ||
| 9 | 9 | --- | |
| 10 | 10 | ||
| 11 | - | ## Phase 9 — Deploy | |
| 12 | - | ||
| 13 | - | ### Remaining | |
| 11 | + | ## Remaining from earlier phases | |
| 14 | 12 | ||
| 15 | 13 | - [ ] Caddy config on alpha-west-1 (reverse proxy alongside MNW, public domain when ready) | |
| 16 | 14 | - [ ] Deploy to alpha-west-1 (hetzner, alongside live MNW deployment) | |
| 15 | + | - [ ] Manual moderation testing (ban, mute, unban/unmute, mod log, admin dashboard) | |
| 16 | + | - [ ] Paginate forum directory (if needed, when many projects exist) | |
| 17 | + | ||
| 18 | + | --- | |
| 19 | + | ||
| 20 | + | Phases 13-24 ordered smallest to largest. | |
| 17 | 21 | ||
| 18 | 22 | --- | |
| 19 | 23 | ||
| 20 | - | ## Phase 10 — Polish + UX | |
| 24 | + | ## Phase 13 — Draft Auto-Save | |
| 25 | + | ||
| 26 | + | localStorage draft persistence. Prevents lost work on accidental navigation. | |
| 21 | 27 | ||
| 22 | 28 | ### Remaining | |
| 23 | 29 | ||
| 24 | - | - [ ] Paginate forum directory (if needed, when many projects exist) | |
| 30 | + | - [ ] JS: on reply/new-thread textarea `input` event (debounced 1s), save content to `localStorage` keyed by page URL | |
| 31 | + | - [ ] On page load: if draft exists for current URL, restore textarea content + show subtle "Draft restored" indicator | |
| 32 | + | - [ ] Clear draft on successful form submission | |
| 33 | + | - [ ] "Discard draft" link next to the indicator | |
| 34 | + | - [ ] Drafts expire after 7 days (check timestamp on restore, discard if stale) | |
| 35 | + | - [ ] Respects Phase 14 privacy toggle — if localStorage tracking is off, no drafts saved either | |
| 36 | + | - [ ] No server-side storage, no sync across devices | |
| 25 | 37 | ||
| 26 | 38 | --- | |
| 27 | 39 | ||
| 28 | - | ## Audit Findings (2026-03-14, first formal audit) | |
| 40 | + | ## Phase 14 — Immutable Posts + Footnotes | |
| 29 | 41 | ||
| 30 | - | ### Done | |
| 42 | + | Posts are permanent records. No user-facing delete or edit. Corrections via author footnotes. | |
| 31 | 43 | ||
| 32 | - | - [x] **[HIGH]** Sanitize URL schemes in markdown rendering — allowlist (http, https, mailto, ftp), dangerous URLs replaced with `#`. 7 new tests. (`src/markdown.rs`) | |
| 33 | - | - [x] **[MEDIUM]** Add `#[instrument(skip_all)]` — 86 annotations: 42 in src/ (routes, auth, mod) + 44 in crates/mt-db/ (queries, mutations) | |
| 34 | - | - [x] **[MEDIUM]** Make session cookie `Secure` flag configurable via `COOKIE_SECURE` env var, defaults to `true` (`src/config.rs`, `src/main.rs`) | |
| 35 | - | - [x] **[MEDIUM]** Wrap `swap_category_order` in a database transaction (`crates/mt-db/src/mutations.rs`) | |
| 36 | - | - [x] **[MEDIUM]** Change fail-open access checks to fail-closed — DB errors on ban/mute/suspension now return 500 or block login (`src/routes/mod.rs`, `src/auth.rs`) | |
| 44 | + | ### Remaining | |
| 37 | 45 | ||
| 38 | - | - [x] **[SMALL]** Add `//!` module docs to lib.rs, mt-core/lib.rs, mt-core/time_format.rs, mt-db/lib.rs, mt-db/queries.rs, mt-db/mutations.rs | |
| 39 | - | - [x] **[SMALL]** Remove dead code: `CoreError` enum + error.rs, unused model structs + models.rs, `pool::create_pool()` + pool.rs, non-paginated `list_threads_in_category`. Cleaned unused deps from mt-core and mt-db Cargo.toml. | |
| 40 | - | - [x] **[SMALL]** Log mod log insert failures with `tracing::error!` instead of `let _ =` (15 locations across 4 files) | |
| 41 | - | - [x] **[SMALL]** Expand `.env.example` with all required environment variables (DATABASE_URL, OAUTH_CLIENT_ID, MNW_BASE_URL, OAUTH_REDIRECT_URI, HOST, PORT, COOKIE_SECURE, RUST_LOG, PLATFORM_ADMIN_ID) | |
| 46 | + | #### Remove edit/delete for users | |
| 47 | + | - [ ] Remove "Edit" and "Delete" action links from post UI (keep mod "Remove" action) | |
| 48 | + | - [ ] Remove `edit_post` and `delete_post` user routes (keep mod-level removal) | |
| 49 | + | - [ ] Remove edit window logic (`POST_EDIT_WINDOW_SECONDS`) | |
| 50 | + | - [ ] Update thread edit: title editing removed for authors (mods can still rename for housekeeping) | |
| 51 | + | - [ ] Mod "Remove" replaces content with "[removed by moderator]" — original stays in DB for audit, hidden from display | |
| 52 | + | - [ ] Update integration tests: remove edit/delete happy-path tests, add tests for footnotes + mod removal | |
| 42 | 53 | ||
| 43 | - | - [x] **[SMALL]** Initial git commit + configure remotes (git.makenot.work, sr.ht) | |
| 54 | + | #### Author footnotes | |
| 55 | + | - [ ] Migration: `post_footnotes` table — `(id UUID, post_id UUID, author_id UUID, body TEXT, created_at TIMESTAMPTZ)` | |
| 56 | + | - [ ] "Add footnote" link on own posts (same position as old edit link, `.post-action-link` styling) | |
| 57 | + | - [ ] `POST /p/{slug}/{cat}/{thread_id}/{post_id}/footnote` endpoint — author-only, markdown body | |
| 58 | + | - [ ] Footnotes rendered below post body in distinct block: smaller font, muted color, "Author's note (2h ago):" prefix | |
| 59 | + | - [ ] Multiple footnotes per post, displayed in chronological order | |
| 60 | + | - [ ] Footnotes are immutable — no editing or deleting footnotes | |
| 61 | + | - [ ] Footnotes go through same markdown + XSS pipeline as posts | |
| 62 | + | - [ ] Integration tests: add footnote, non-author rejected, footnote renders, multiple footnotes ordered | |
| 44 | 63 | ||
| 45 | 64 | --- | |
| 46 | 65 | ||
| 47 | - | ## Phase 12 — Deploy + Harden | |
| 66 | + | ## Phase 15 — Endorsements | |
| 67 | + | ||
| 68 | + | Silent appreciation. No counts displayed publicly, no notifications. Reduces "+1" noise replies. | |
| 48 | 69 | ||
| 49 | 70 | ### Remaining | |
| 50 | 71 | ||
| 51 | - | - [x] Deploy v0.2.0 to astra (version bump, build, upload, restart) | |
| 52 | - | - [x] Set PLATFORM_ADMIN_ID in production .env | |
| 53 | - | - [ ] Manual moderation testing (ban a user, verify 403; mute, verify read-only; unban/unmute; mod log entries; admin dashboard) | |
| 54 | - | - [ ] Caddy config on alpha-west-1 (reverse proxy alongside MNW, public domain when ready) | |
| 55 | - | - [x] Rate limiting (per-IP on write endpoints, tower-governor, burst 10 / 2/sec) | |
| 56 | - | - [x] Expired ban cleanup (opportunistic on moderation page view, `cleanup_expired_bans` in mutations.rs) | |
| 72 | + | - [ ] Migration: `post_endorsements` table — `(post_id UUID, user_id UUID, created_at TIMESTAMPTZ)`, primary key `(post_id, user_id)` | |
| 73 | + | - [ ] "Endorse" button on each post (not on own posts, logged-in only). Toggle — click again to remove. | |
| 74 | + | - [ ] `POST /p/{slug}/{cat}/{thread_id}/{post_id}/endorse` — toggle endpoint | |
| 75 | + | - [ ] No public count displayed on posts. No notification to author. | |
| 76 | + | - [ ] Author can see endorsement count on their own posts (subtle, e.g. "3 endorsements" in muted text visible only to them) | |
| 77 | + | - [ ] Community profile (Phase 18): total endorsements received as a profile stat | |
| 78 | + | - [ ] Mods can see endorsement counts on all posts (quality signal for moderation) | |
| 79 | + | - [ ] Integration tests: endorse, un-endorse, no self-endorse, count visible to author only | |
| 80 | + | ||
| 81 | + | --- | |
| 82 | + | ||
| 83 | + | ## Phase 16 — Unread/New Tracking | |
| 84 | + | ||
| 85 | + | Two-tier system: local by default (no server cost), opt-in server-side for tracked threads. | |
| 86 | + | ||
| 87 | + | ### Remaining | |
| 88 | + | ||
| 89 | + | #### Tier 1 — Local (localStorage, no login required) | |
| 90 | + | - [ ] On thread listing pages, emit `data-thread-id` and `data-reply-count` on each `<tr>` | |
| 91 | + | - [ ] JS: on page load, compare each thread's reply count against `localStorage` map (`mt_thread_state`). If thread exists but count increased → add `.unread` CSS class (bold title + dot indicator) | |
| 92 | + | - [ ] JS: when user clicks into a thread, update stored count to current | |
| 93 | + | - [ ] LRU cap: evict oldest entries when map exceeds 1000 threads | |
| 94 | + | - [ ] Opt-out toggle: footer link or settings gear, "Unread tracking: On / Off", stored as `localStorage.mt_tracking_enabled`. When off, no reads/writes to thread state, no visual indicators | |
| 95 | + | - [ ] Toggle label explains: "Tracks which threads have new replies. Stored in your browser only — never sent to the server." | |
| 96 | + | ||
| 97 | + | #### Tier 2 — Server-side tracked threads (logged-in, explicit opt-in) | |
| 98 | + | - [ ] Migration: `tracked_threads` table — `(user_id, thread_id, last_read_post_id, tracked_at)` | |
| 99 | + | - [ ] "Track" button on thread page with title text: "Server will remember your read position for this thread across devices" | |
| 100 | + | - [ ] `POST /p/{slug}/{cat}/{thread_id}/track` + `POST .../untrack` endpoints | |
| 101 | + | - [ ] On opening a tracked thread, upsert `last_read_post_id` to latest post | |
| 102 | + | - [ ] Category listing: tracked threads with new posts show count badge ("3 new") distinct from tier-1 bold | |
| 103 | + | - [ ] `/tracked` page: all tracked threads across communities, sorted by recent activity, with unread counts | |
| 104 | + | - [ ] `/tracked` header: "These threads are tracked on the server. Your read position syncs across devices." + "Stop tracking all" action | |
| 105 | + | - [ ] Integration tests: track/untrack, read position update, unread counts, stop-all | |
| 106 | + | ||
| 107 | + | #### Privacy transparency | |
| 108 | + | - [ ] Static page or modal linked from footer: "How tracking works" — explains both tiers, what is stored where, how to opt out/delete | |
| 109 | + | - [ ] Browser tracking: localStorage only, never sent to server, cleared by browser data or toggle | |
| 110 | + | - [ ] Thread tracking: server-side, tied to account, deletable via untrack or stop-all | |
| 111 | + | - [ ] "No analytics, no third-party sharing" | |
| 112 | + | - [ ] Reference MNW principle: "All Makenot.work apps follow the same tracking principles" | |
| 113 | + | ||
| 114 | + | --- | |
| 115 | + | ||
| 116 | + | ## Phase 17 — Post Flagging | |
| 117 | + | ||
| 118 | + | Users flag posts, mods review in a queue. No auto-moderation yet (future: per-community toggle creators can enable). | |
| 119 | + | ||
| 120 | + | ### Remaining | |
| 121 | + | ||
| 122 | + | #### Data model | |
| 123 | + | - [ ] Migration: `post_flags` table — `(id UUID, post_id UUID, flagger_id UUID, reason ENUM('spam','rule_breaking','off_topic'), detail TEXT NULL, created_at TIMESTAMPTZ, resolved_at TIMESTAMPTZ NULL, resolved_by UUID NULL, resolution ENUM('dismissed','removed') NULL)` | |
| 124 | + | - [ ] Unique constraint on `(post_id, flagger_id)` — one flag per user per post | |
| 125 | + | ||
| 126 | + | #### User-facing | |
| 127 | + | - [ ] "Flag" link on each post (`.post-action-link` styling, not on own posts) | |
| 128 | + | - [ ] Flag form: radio buttons for reason (Spam, Rule-breaking, Off-topic) + optional freetext detail | |
| 129 | + | - [ ] `POST /p/{slug}/{cat}/{thread_id}/{post_id}/flag` — logged-in only, one per user per post, duplicate silently ignored | |
| 130 | + | - [ ] Visual confirmation: toast "Post flagged" on success | |
| 131 | + | ||
| 132 | + | #### Mod review | |
| 133 | + | - [ ] "Flags" section on moderation page (`/p/{slug}/moderation`) showing pending flags | |
| 134 | + | - [ ] Each flag entry: post content preview, flag reason, detail if provided, flagger username, timestamp, flag count (if multiple users flagged same post) | |
| 135 | + | - [ ] Mod actions: "Dismiss" (resolve flag, no action) or "Remove" (mod-remove post + resolve flag + mod log entry) | |
| 136 | + | - [ ] Resolved flags hidden from queue, visible in mod log | |
| 137 | + | ||
| 138 | + | #### Tests | |
| 139 | + | - [ ] Integration tests: flag a post, duplicate flag rejected, non-logged-in rejected, mod dismiss, mod remove + flag resolved, flag count display | |
| 140 | + | ||
| 141 | + | #### Deferred: auto-moderation | |
| 142 | + | - [ ] Future: per-community setting `auto_hide_threshold` (default: off). When flag count reaches threshold, post auto-hidden pending mod review. Creators can enable, MNW official forums keep it off. | |
| 143 | + | ||
| 144 | + | --- | |
| 145 | + | ||
| 146 | + | ## Phase 18 — Tags | |
| 147 | + | ||
| 148 | + | Lightweight cross-cutting labels on threads. Creator-defined per community. | |
| 149 | + | ||
| 150 | + | ### Remaining | |
| 151 | + | ||
| 152 | + | - [ ] Migration: `tags` table — `(id UUID, community_id UUID, name TEXT, slug TEXT, created_at TIMESTAMPTZ)`, unique on `(community_id, slug)`. `thread_tags` join table — `(thread_id UUID, tag_id UUID)`. | |
| 153 | + | - [ ] Community settings (owner): create/delete tags for their community | |
| 154 | + | - [ ] Thread creation + thread view: tag selector (multi-select from community's tag list) | |
| 155 | + | - [ ] Tags displayed on thread listing rows (small badges after title) | |
| 156 | + | - [ ] Category view: filter by tag via query param (`?tag=solved`) | |
| 157 | + | - [ ] Common patterns: `[solved]`, `[bug]`, `[question]`, `[announcement]` — but no built-in defaults, fully creator-defined | |
| 158 | + | - [ ] Integration tests: create tag, apply to thread, filter by tag, remove tag, tag scoped to community | |
| 159 | + | ||
| 160 | + | --- | |
| 161 | + | ||
| 162 | + | ## Phase 19 — @Mentions (passive) | |
| 163 | + | ||
| 164 | + | `@username` renders as a link. Mentioned threads get a distinct visual indicator on next visit. No notifications, no email, no push. | |
| 165 | + | ||
| 166 | + | ### Remaining | |
| 167 | + | ||
| 168 | + | - [ ] Parse `@username` in post body during markdown rendering — render as link to community-scoped profile (`/p/{slug}/u/{username}`) | |
| 169 | + | - [ ] Migration: `post_mentions` table — `(post_id UUID, mentioned_user_id UUID, created_at TIMESTAMPTZ)` | |
| 170 | + | - [ ] On post creation: extract `@username` tokens, resolve to user IDs, insert into `post_mentions` | |
| 171 | + | - [ ] Thread listing: threads where the current user was mentioned since their last visit get a violet (`#6c5ce7`) accent indicator — distinct from unread bold (tier-1) and tracked count badge (tier-2) | |
| 172 | + | - [ ] `/tracked` page (Phase 16): mention-highlighted threads appear in a "Mentioned" section | |
| 173 | + | - [ ] No notification, no email, no badge count. Just: when you visit, threads with your mentions are visually distinct. | |
| 174 | + | - [ ] Integration tests: mention parsed and stored, mention renders as profile link, mention indicator on thread listing, self-mention ignored | |
| 175 | + | ||
| 176 | + | --- | |
| 177 | + | ||
| 178 | + | ## Phase 20 — Link Previews (minimal) | |
| 179 | + | ||
| 180 | + | Server-side OpenGraph fetch on post creation. No embeds, no iframes, no third-party JS. | |
| 181 | + | ||
| 182 | + | ### Remaining | |
| 183 | + | ||
| 184 | + | - [ ] On post creation: extract URLs from body, fetch OpenGraph `og:title` + `og:description` for each (server-side, with timeout + size limit) | |
| 185 | + | - [ ] Store preview data with the post (JSON column or separate `link_previews` table — `(post_id, url, title, description)`) | |
| 186 | + | - [ ] Render below the link in the post: small card with title + description, linked to URL. Muted styling, no images. | |
| 187 | + | - [ ] Fetch happens once at post creation time, not on every render. If OG fetch fails, plain link, no retry. | |
| 188 | + | - [ ] Rate limit: max 3 previews per post. Skip URLs to known-problematic domains. | |
| 189 | + | - [ ] No YouTube embeds, no iframe injection, no tracking pixels from third-party content. | |
| 190 | + | - [ ] Integration tests: link preview stored on create, failed fetch degrades to plain link, preview renders | |
| 191 | + | ||
| 192 | + | --- | |
| 193 | + | ||
| 194 | + | ## Phase 21 — Verified Quoting | |
| 195 | + | ||
| 196 | + | Select-to-quote with hash verification. Quoted text is validated against original on submit. | |
| 197 | + | ||
| 198 | + | ### Remaining | |
| 199 | + | ||
| 200 | + | #### Quote format | |
| 201 | + | - [ ] Quote block format: `> quoted text\n> <quote post="POST_ID" hash="HASH">` where hash is truncated SHA-256 (6 hex chars) of quoted text | |
| 202 | + | - [ ] On render: strip `<quote>` metadata line, render attribution as "— @username, post #N" link below blockquote | |
| 203 | + | - [ ] Markdown renderer: recognize and handle `<quote>` tag (strip from rendered output, replace with attribution) | |
| 204 | + | ||
| 205 | + | #### Selection + insertion | |
| 206 | + | - [ ] Text selection UX: user selects text within a `.post-body`, "Quote" button appears (floating near selection or as a popup) | |
| 207 | + | - [ ] JS: capture selected text, compute SHA-256 hash via SubtleCrypto, insert formatted blockquote into reply textarea | |
| 208 | + | - [ ] If textarea has existing content, append quote (multi-quote support) | |
| 209 | + | - [ ] Scroll to reply form + focus cursor after inserted quote | |
| 210 | + | ||
| 211 | + | #### Server-side verification | |
| 212 | + | - [ ] On reply submit: extract all `<quote post="X" hash="Y">` from body | |
| 213 | + | - [ ] For each: fetch post X body, verify quoted text is a substring, verify hash matches | |
| 214 | + | - [ ] Reject with clear error if any quote is fabricated or altered: "Quote doesn't match the original post" | |
| 215 | + | - [ ] No edge case for edited posts — posts are immutable (Phase 14), hash always valid | |
| 216 | + | - [ ] Integration tests: valid quote accepted, fabricated quote rejected, altered quote rejected, multi-quote post, quote from nonexistent post rejected | |
| 217 | + | ||
| 218 | + | --- | |
| 219 | + | ||
| 220 | + | ## Phase 22 — User Profiles | |
| 221 | + | ||
| 222 | + | Per-community profiles (not MT-global). Each forum is its own world. MNW Forums tab is the only aggregate view. | |
| 223 | + | ||
| 224 | + | ### Remaining | |
| 225 | + | ||
| 226 | + | #### Community-scoped profile page (`/p/{slug}/u/{username}`) | |
| 227 | + | - [ ] Route: `GET /p/{slug}/u/{username}` — public, anyone can view | |
| 228 | + | - [ ] Pull avatar + display name from stored MNW userinfo (already have `avatar_url`, `display_name` from OAuth) | |
| 229 | + | - [ ] Display: avatar, display name, join date for this community (membership `created_at`), post count in this community | |
| 230 | + | - [ ] Recent activity: list of recent posts/threads in this community only (paginated, newest first) | |
| 231 | + | - [ ] Role badge for this community (member/moderator/owner) | |
| 232 | + | - [ ] Template + CSS (reuse existing design patterns — data-table for activity, badge styles for roles) | |
| 233 | + | - [ ] Query: user activity scoped to single community (posts + threads in community, membership with role) | |
| 234 | + | - [ ] Usernames in thread/post listings link to `/p/{slug}/u/{username}` (community-scoped, not MNW profile) | |
| 235 | + | ||
| 236 | + | #### MT user summary API | |
| 237 | + | - [ ] `GET /api/user/{user_id}/summary` — returns JSON: list of memberships (community name, slug, role, join date, post count), tracked thread counts per community | |
| 238 | + | - [ ] Auth: requires valid MNW session or internal service token (MNW server-to-server) | |
| 239 | + | - [ ] Lightweight — MNW fetches this when rendering the account page Forums tab | |
| 240 | + | ||
| 241 | + | #### MNW Forums tab (on MNW account page) | |
| 242 | + | - [ ] New tab on MNW account/settings page, hidden until user has >= 1 MT membership | |
| 243 | + | - [ ] MNW server fetches MT summary API on tab render | |
| 244 | + | - [ ] Shows: communities with roles, tracked threads with unread counts, per-community settings links | |
| 245 | + | - [ ] Each community links to community-scoped profile (`{mt_base_url}/p/{slug}/u/{username}`) | |
| 246 | + | - [ ] Private — only visible to account owner | |
| 247 | + | - [ ] MNW-side: new route handler, template partial, config for MT API base URL | |
| 248 | + | ||
| 249 | + | --- | |
| 250 | + | ||
| 251 | + | ## Phase 23 — Search (fuzzy-find modal) | |
| 252 | + | ||
| 253 | + | Instant fuzzy search modal — press `/` or click search icon, results stream in as you type. No page reload. | |
| 254 | + | ||
| 255 | + | ### Remaining | |
| 256 | + | ||
| 257 | + | - [ ] Migration: `CREATE EXTENSION pg_trgm`, add GIN trigram indexes on `threads.title` and `posts.body`, add tsvector columns + GIN indexes for full-text ranking | |
| 258 | + | - [ ] Search query: combine `pg_trgm` similarity (typo tolerance) + `tsvector`/`ts_rank` (stemming + relevance), title matches ranked above body, recency boost for tiebreaks | |
| 259 | + | - [ ] Search endpoint: `GET /search?q=...&scope=...` returning HTML fragments (HTMX-compatible), scoped to community when on `/p/{slug}` with "search all" toggle | |
| 260 | + | - [ ] Search modal UI: overlay triggered by `/` key or header search icon, focused input, HTMX `hx-get` with `keyup changed delay:150ms`, results rendered inline (thread title with bolded match, body snippet, community/category breadcrumb, relative timestamp) | |
| 261 | + | - [ ] Keyboard navigation: arrow keys to move selection, Enter to navigate, Esc to close | |
| 262 | + | - [ ] Integration tests: search by exact title, fuzzy/typo match, body content match, scoped vs global, empty query returns nothing | |
| 263 | + | ||
| 264 | + | --- | |
| 265 | + | ||
| 266 | + | ## Phase 24 — Image Uploads | |
| 267 | + | ||
| 268 | + | Inline images in posts via S3. Drag-and-drop + paste support. | |
| 269 | + | ||
| 270 | + | ### Remaining | |
| 271 | + | ||
| 272 | + | - [ ] S3 bucket configuration (env vars: bucket, region, credentials — reuse MNW S3 infra if possible) | |
| 273 | + | - [ ] `POST /p/{slug}/upload` endpoint — logged-in only, accepts image files, returns markdown image link | |
| 274 | + | - [ ] File validation: image types only (png, jpg, gif, webp), max size (e.g. 5MB), strip EXIF metadata | |
| 275 | + | - [ ] JS: drag-and-drop + paste handler on reply/new-thread textarea — upload file, insert `` at cursor | |
| 276 | + | - [ ] Upload progress indicator (replace placeholder text while uploading) | |
| 277 | + | - [ ] Rendered images: max-width constrained, clickable to view full size (lightbox or new tab) | |
| 278 | + | - [ ] Rate limit: max uploads per user per hour | |
| 279 | + | - [ ] Mod tools: ability to remove uploaded images (removes from S3 + replaces in post with "[image removed]") | |
| 280 | + | - [ ] Integration tests: upload image, invalid type rejected, oversized rejected, image renders in post | |
| 57 | 281 | ||
| 58 | 282 | --- | |
| 59 | 283 | ||
| 60 | - | ## Deferred (Post-Alpha) | |
| 284 | + | ## Deferred (Post-Beta) | |
| 61 | 285 | ||
| 62 | 286 | - [ ] E2E encrypted live chat (OpenMLS integration, WebSocket gateway) | |
| 63 | 287 | - [ ] Real-time thread updates (WebSocket or SSE for new posts) | |
| 64 | - | - [ ] Search (full-text search across threads and posts) | |
| 65 | - | - [ ] Notifications (new replies to your threads, mentions) | |
| 66 | - | - [ ] File attachments (images in posts, via S3) | |
| 67 | - | - [ ] User avatars | |
| 68 | 288 | - [ ] Community creation by users (currently admin-seeded only) | |
| 69 | 289 | - [ ] Private communities (invite-only, hidden from listing) | |
| 70 | - | - [ ] Thread subscriptions / watch | |
| 71 | - | - [ ] Reporting / content flagging | |
| 72 | - | - [ ] RSS feeds per community/category | |
| 73 | 290 | - [ ] Federation (ActivityPub or custom protocol) | |
| 291 | + | - [ ] Subcategories / nested categories | |
| 292 | + | - [ ] Similar thread detection on new thread creation | |
| 293 | + | - [ ] Suggested/related threads at bottom of thread view | |
| 294 | + | - [ ] Keyboard shortcuts beyond `/` for search | |
| 74 | 295 | ||
| 75 | 296 | --- | |
| 76 | 297 |