Skip to main content

max / multithreaded

UX navigation fixes, seed data expansion, styling improvements Navigation: footer adds MNW links (pricing/creators/docs/policy), header shows username instead of generic Profile, admin link for platform admin users. is_platform_admin plumbed through all templates. Seed data: expanded for development/testing. Community page: layout improvements. Pagination tests: updated for new template fields. CSS: footer-links styling, misc refinements. Version bump to 0.2.2 for deploy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-15 02:33 UTC
Commit: 289ed0bf0bd84b0c074bb18e30810b4e148b67fa
Parent: e69bb8c
15 files changed, +866 insertions, -215 deletions
M Cargo.lock +3 -3
@@ -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",
M Cargo.toml +1 -1
@@ -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,
M src/seed.rs +379 -91
@@ -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,
M static/style.css +64 -68
@@ -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">&middot;</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"
M todo.md +254 -33
@@ -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 `![](url)` 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