max / makenotwork
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> · 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 |