Skip to main content

max / makenotwork

I2 completion: discussion section on all item types, community link on project page - Extract fetch_discussion_info() from content.rs and blog.rs into helpers.rs - Add discussion_url/discussion_count to ItemTemplate (generic items now show discussion section) - Add community_url to ProjectTemplate (links to paired MT forum when provisioned) - Wire MT community provisioning into project creation wizard - Wire MT thread creation into item publish wizard - Include discussion_section.html partial on generic item page - Add Community link button to project page store-actions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-22 15:17 UTC
Commit: 31ba42470f3b696515b04cf21da961fe027f78eb
Parent: cd4064d
8 files changed, +129 insertions, -87 deletions
@@ -6,6 +6,8 @@ use axum::http::StatusCode;
6 6 use axum::response::{IntoResponse, Response};
7 7 use tower_sessions::Session;
8 8
9 + use crate::AppState;
10 +
9 11 /// Check whether the incoming request was made by HTMX.
10 12 pub fn is_htmx_request(headers: &HeaderMap) -> bool {
11 13 headers.get("HX-Request").is_some()
@@ -286,6 +288,48 @@ pub fn verify_feed_signature(user_id: crate::db::UserId, signature: &str, secret
286 288 constant_time_compare(&expected, signature)
287 289 }
288 290
291 + /// Fetch MT discussion thread stats (URL + post count) for a linked thread.
292 + /// Returns (discussion_url, discussion_count) — both None if MT unavailable or no linked thread.
293 + pub async fn fetch_discussion_info(
294 + state: &AppState,
295 + mt_thread_id: Option<uuid::Uuid>,
296 + project_slug: &str,
297 + category_slug: &str,
298 + ) -> (Option<String>, Option<i64>) {
299 + let Some(thread_id) = mt_thread_id else {
300 + return (None, None);
301 + };
302 + let Some(ref mt) = state.mt_client else {
303 + return (None, None);
304 + };
305 + let Some(ref mt_base_url) = state.config.mt_base_url else {
306 + return (None, None);
307 + };
308 +
309 + let url = format!(
310 + "{}/p/{}/{}/{}",
311 + mt_base_url, project_slug, category_slug, thread_id
312 + );
313 +
314 + match tokio::time::timeout(
315 + std::time::Duration::from_secs(2),
316 + mt.get_thread_stats(thread_id),
317 + )
318 + .await
319 + {
320 + Ok(Ok(stats)) => (Some(url), Some(stats.post_count)),
321 + Ok(Err(e)) => {
322 + tracing::debug!(error = ?e, "failed to fetch MT thread stats");
323 + // Still return the URL even if stats failed
324 + (Some(url), None)
325 + }
326 + Err(_) => {
327 + tracing::debug!("MT thread stats request timed out");
328 + (Some(url), None)
329 + }
330 + }
331 + }
332 +
289 333 #[cfg(test)]
290 334 mod tests {
291 335 use super::*;
@@ -12,52 +12,12 @@ use crate::{
12 12 auth::MaybeUser,
13 13 db::{self, Slug},
14 14 error::{AppError, Result},
15 - helpers::{get_csrf_token, get_initials},
15 + helpers::{fetch_discussion_info, get_csrf_token, get_initials},
16 16 templates::*,
17 17 types::*,
18 18 AppState,
19 19 };
20 20
21 - /// Fetch MT discussion thread stats (URL + post count) for a linked thread.
22 - async fn fetch_discussion_info(
23 - state: &AppState,
24 - mt_thread_id: Option<uuid::Uuid>,
25 - project_slug: &str,
26 - category_slug: &str,
27 - ) -> (Option<String>, Option<i64>) {
28 - let Some(thread_id) = mt_thread_id else {
29 - return (None, None);
30 - };
31 - let Some(ref mt) = state.mt_client else {
32 - return (None, None);
33 - };
34 - let Some(ref mt_base_url) = state.config.mt_base_url else {
35 - return (None, None);
36 - };
37 -
38 - let url = format!(
39 - "{}/p/{}/{}/{}",
40 - mt_base_url, project_slug, category_slug, thread_id
41 - );
42 -
43 - match tokio::time::timeout(
44 - std::time::Duration::from_secs(2),
45 - mt.get_thread_stats(thread_id),
46 - )
47 - .await
48 - {
49 - Ok(Ok(stats)) => (Some(url), Some(stats.post_count)),
50 - Ok(Err(e)) => {
51 - tracing::debug!(error = ?e, "failed to fetch MT thread stats");
52 - (Some(url), None)
53 - }
54 - Err(_) => {
55 - tracing::debug!("MT thread stats request timed out");
56 - (Some(url), None)
57 - }
58 - }
59 - }
60 -
61 21 /// Register blog page routes.
62 22 pub fn blog_routes() -> Router<AppState> {
63 23 Router::new()
@@ -190,7 +190,7 @@ pub async fn step_save(
190 190 "content" => save_content(&state, &item, &form).await?,
191 191 "pricing" => save_pricing(&state, &item, &form).await?,
192 192 "distribution" => save_distribution(&state, &item, &form).await?,
193 - "preview" => return save_preview(&state, &project, &item, &form).await,
193 + "preview" => return save_preview(&state, &user, &project, &item, &form).await,
194 194 _ => return Err(AppError::NotFound),
195 195 }
196 196
@@ -350,6 +350,7 @@ async fn save_distribution(
350 350
351 351 async fn save_preview(
352 352 state: &AppState,
353 + user: &crate::auth::SessionUser,
353 354 _project: &db::DbProject,
354 355 item: &db::DbItem,
355 356 form: &HashMap<String, String>,
@@ -371,6 +372,19 @@ async fn save_preview(
371 372 None,
372 373 )
373 374 .await?;
375 +
376 + // Re-fetch to get updated is_public state
377 + let updated = db::items::get_item_by_id(&state.db, item.id)
378 + .await?
379 + .ok_or(AppError::NotFound)?;
380 +
381 + if updated.is_public {
382 + crate::scheduler::send_release_announcements(state, &updated).await;
383 +
384 + if updated.mt_thread_id.is_none() {
385 + crate::scheduler::spawn_mt_thread_for_item(state, &updated, user);
386 + }
387 + }
374 388 }
375 389 "schedule" => {
376 390 if let Some(datetime_str) = form.get("publish_at")
@@ -188,7 +188,7 @@ pub async fn step_save(
188 188 "appearance" => save_appearance(&state, &project, &form).await?,
189 189 "monetization" => save_monetization(&state, &user, &project, &form).await?,
190 190 "first-content" => save_first_content(&state, &project, &form).await?,
191 - "preview" => return save_preview(&state, &project, &form).await,
191 + "preview" => return save_preview(&state, &user, &project, &form).await,
192 192 _ => return Err(AppError::NotFound),
193 193 }
194 194
@@ -277,6 +277,7 @@ async fn save_first_content(
277 277
278 278 async fn save_preview(
279 279 state: &AppState,
280 + user: &crate::auth::SessionUser,
280 281 project: &db::DbProject,
281 282 form: &HashMap<String, String>,
282 283 ) -> Result<Response> {
@@ -292,6 +293,44 @@ async fn save_preview(
292 293 Some(true),
293 294 )
294 295 .await?;
296 +
297 + // Fire-and-forget: provision a paired MT community
298 + if project.mt_community_id.is_none() {
299 + if let Some(ref mt) = state.mt_client {
300 + let mt = mt.clone();
301 + let db = state.db.clone();
302 + let project_id = project.id;
303 + let slug = project.slug.to_string();
304 + let title = project.title.clone();
305 + let desc = project.description.clone();
306 + let username = user.username.to_string();
307 + let display_name = user.display_name.clone();
308 + let user_id = user.id;
309 + tokio::spawn(async move {
310 + match mt
311 + .create_community(&crate::mt_client::CreateCommunityRequest {
312 + name: title,
313 + slug,
314 + description: desc,
315 + owner_mnw_id: *user_id,
316 + owner_username: username,
317 + owner_display_name: display_name,
318 + })
319 + .await
320 + {
321 + Ok(resp) => {
322 + if let Err(e) =
323 + db::projects::set_mt_community_id(&db, project_id, resp.community_id)
324 + .await
325 + {
326 + tracing::warn!(error = ?e, "failed to store MT community ID");
327 + }
328 + }
329 + Err(e) => tracing::warn!(error = ?e, "MT community provisioning failed"),
330 + }
331 + });
332 + }
333 + }
295 334 }
296 335
297 336 // Redirect to the project dashboard
@@ -11,7 +11,7 @@ use crate::{
11 11 auth::MaybeUser,
12 12 db::{self, ContentData, FollowTargetType, ItemId, ItemType, Slug, Username},
13 13 error::{AppError, Result},
14 - helpers::{get_csrf_token, get_initials},
14 + helpers::{fetch_discussion_info, get_csrf_token, get_initials},
15 15 templates::*,
16 16 types::*,
17 17 AppState,
@@ -23,48 +23,6 @@ pub struct PurchaseQuery {
23 23 pub code: Option<String>,
24 24 }
25 25
26 - /// Fetch MT discussion thread stats (URL + post count) for a linked thread.
27 - /// Returns (discussion_url, discussion_count) — both None if MT unavailable or no linked thread.
28 - async fn fetch_discussion_info(
29 - state: &AppState,
30 - mt_thread_id: Option<uuid::Uuid>,
31 - project_slug: &str,
32 - category_slug: &str,
33 - ) -> (Option<String>, Option<i64>) {
34 - let Some(thread_id) = mt_thread_id else {
35 - return (None, None);
36 - };
37 - let Some(ref mt) = state.mt_client else {
38 - return (None, None);
39 - };
40 - let Some(ref mt_base_url) = state.config.mt_base_url else {
41 - return (None, None);
42 - };
43 -
44 - let url = format!(
45 - "{}/p/{}/{}/{}",
46 - mt_base_url, project_slug, category_slug, thread_id
47 - );
48 -
49 - match tokio::time::timeout(
50 - std::time::Duration::from_secs(2),
51 - mt.get_thread_stats(thread_id),
52 - )
53 - .await
54 - {
55 - Ok(Ok(stats)) => (Some(url), Some(stats.post_count)),
56 - Ok(Err(e)) => {
57 - tracing::debug!(error = ?e, "failed to fetch MT thread stats");
58 - // Still return the URL even if stats failed
59 - (Some(url), None)
60 - }
61 - Err(_) => {
62 - tracing::debug!("MT thread stats request timed out");
63 - (Some(url), None)
64 - }
65 - }
66 - }
67 -
68 26 /// Render a public user profile page with projects and custom links.
69 27 #[tracing::instrument(skip_all, name = "content::user_page")]
70 28 pub(super) async fn user_page(
@@ -198,6 +156,15 @@ pub(super) async fn project_page(
198 156 // Check if this project has any published blog posts
199 157 let has_blog_posts = db::blog_posts::has_published_posts(&state.db, db_project.id).await?;
200 158
159 + // Community link: if project has a paired MT community, link to it
160 + let community_url = if db_project.mt_community_id.is_some() {
161 + state.config.mt_base_url.as_ref().map(|base| {
162 + format!("{}/p/{}", base, db_project.slug)
163 + })
164 + } else {
165 + None
166 + };
167 +
201 168 Ok(ProjectTemplate {
202 169 csrf_token,
203 170 session_user: maybe_user,
@@ -213,6 +180,7 @@ pub(super) async fn project_page(
213 180 git_repos,
214 181 project_labels,
215 182 has_blog_posts,
183 + community_url,
216 184 })
217 185 }
218 186
@@ -366,15 +334,21 @@ pub(super) async fn item_page(
366 334
367 335 let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
368 336
337 + let project_slug_str = db_project.slug.to_string();
338 + let (discussion_url, discussion_count) =
339 + fetch_discussion_info(&state, db_item.mt_thread_id, &project_slug_str, "items").await;
340 +
369 341 Ok(ItemTemplate {
370 342 csrf_token,
371 343 session_user: maybe_user,
372 344 item,
373 345 creator_username: db_user.username.to_string(),
374 346 project_title: db_project.title,
375 - project_slug: db_project.slug.to_string(),
347 + project_slug: project_slug_str,
376 348 versions,
377 349 host_url: state.config.host_url.clone(),
350 + discussion_url,
351 + discussion_count,
378 352 }.into_response())
379 353 }
380 354
@@ -207,6 +207,8 @@ pub struct ProjectTemplate {
207 207 pub project_labels: Vec<crate::db::DbLabel>,
208 208 /// Whether this project has any published blog posts.
209 209 pub has_blog_posts: bool,
210 + /// URL to the paired MT community forum (None if no community provisioned).
211 + pub community_url: Option<String>,
210 212 }
211 213
212 214 /// Public item detail page.
@@ -222,6 +224,10 @@ pub struct ItemTemplate {
222 224 pub versions: Vec<Version>,
223 225 /// Base URL for OG meta tags.
224 226 pub host_url: String,
227 + /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
228 + pub discussion_url: Option<String>,
229 + /// Number of posts in the linked discussion thread.
230 + pub discussion_count: Option<i64>,
225 231 }
226 232
227 233 /// Blog/article reader view.
@@ -221,6 +221,8 @@
221 221 </section>
222 222 {% endif %}
223 223
224 + {% include "partials/discussion_section.html" %}
225 +
224 226 <footer class="item-footer">
225 227 <p>Powered by <a href="/">Makenot<span class="dot">.</span>work</a> &middot; Fair distribution for creators</p>
226 228 <p style="margin-top: 0.5rem;">
@@ -124,6 +124,9 @@
124 124 {% for repo in &git_repos %}
125 125 <a href="{{ repo.1 }}" class="secondary" style="display: inline-block; padding: 0.5rem 1rem; text-decoration: none;">{{ repo.0 }}</a>
126 126 {% endfor %}
127 + {% if let Some(url) = community_url %}
128 + <a href="{{ url }}" class="secondary" style="display: inline-block; padding: 0.5rem 1rem; text-decoration: none;">Community</a>
129 + {% endif %}
127 130 </div>
128 131 </header>
129 132