Skip to main content

max / makenotwork

Item sections, Fan+ landing section, v0.3.21 Add tabbed markdown content sections to items (Features, Installation, Specs, etc.) with full CRUD API, wizard step, public page rendering with URL hash deep-linking, and dashboard management UI. Migration 054, 9 integration tests. Add Fan+ subscription teaser to index page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-08 02:35 UTC
Commit: 83079a3348e50a93a184ebdcaa3e983e96629d67
Parent: 550b5f3
28 files changed, +1155 insertions, -3 deletions
M Cargo.lock +1 -1
@@ -3351,7 +3351,7 @@ dependencies = [
3351 3351
3352 3352 [[package]]
3353 3353 name = "makenotwork"
3354 - version = "0.3.19"
3354 + version = "0.3.20"
3355 3355 dependencies = [
3356 3356 "anyhow",
3357 3357 "argon2",
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.3.20"
3 + version = "0.3.21"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -0,0 +1,14 @@
1 + -- Item sections: tabbed markdown content blocks within items.
2 + CREATE TABLE item_sections (
3 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4 + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
5 + title VARCHAR(100) NOT NULL,
6 + slug VARCHAR(120) NOT NULL,
7 + body TEXT NOT NULL DEFAULT '',
8 + sort_order INTEGER NOT NULL DEFAULT 0,
9 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
10 + updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
11 + );
12 +
13 + CREATE UNIQUE INDEX idx_item_sections_item_slug ON item_sections(item_id, slug);
14 + CREATE INDEX idx_item_sections_item_order ON item_sections(item_id, sort_order);
@@ -176,6 +176,7 @@ define_pg_uuid_id!(
176 176 MailingListId,
177 177 MailingListSubscriberId,
178 178 CustomDomainId,
179 + ItemSectionId,
179 180 );
180 181
181 182 #[cfg(test)]
@@ -0,0 +1,120 @@
1 + //! Item section CRUD: tabbed markdown content blocks within items.
2 +
3 + use sqlx::PgPool;
4 +
5 + use super::models::*;
6 + use super::{ItemId, ItemSectionId};
7 + use crate::error::Result;
8 +
9 + /// List all sections for an item, ordered by sort_order.
10 + pub async fn list_by_item(pool: &PgPool, item_id: ItemId) -> Result<Vec<DbItemSection>> {
11 + let sections = sqlx::query_as::<_, DbItemSection>(
12 + "SELECT * FROM item_sections WHERE item_id = $1 ORDER BY sort_order LIMIT 500",
13 + )
14 + .bind(item_id)
15 + .fetch_all(pool)
16 + .await?;
17 +
18 + Ok(sections)
19 + }
20 +
21 + /// Fetch a section by primary key. Returns `None` if not found.
22 + pub async fn get_by_id(pool: &PgPool, section_id: ItemSectionId) -> Result<Option<DbItemSection>> {
23 + let section = sqlx::query_as::<_, DbItemSection>(
24 + "SELECT * FROM item_sections WHERE id = $1",
25 + )
26 + .bind(section_id)
27 + .fetch_optional(pool)
28 + .await?;
29 +
30 + Ok(section)
31 + }
32 +
33 + /// Insert a new section for an item.
34 + pub async fn create(
35 + pool: &PgPool,
36 + item_id: ItemId,
37 + title: &str,
38 + slug: &str,
39 + body: &str,
40 + sort_order: i32,
41 + ) -> Result<DbItemSection> {
42 + let section = sqlx::query_as::<_, DbItemSection>(
43 + r#"
44 + INSERT INTO item_sections (item_id, title, slug, body, sort_order)
45 + VALUES ($1, $2, $3, $4, $5)
46 + RETURNING *
47 + "#,
48 + )
49 + .bind(item_id)
50 + .bind(title)
51 + .bind(slug)
52 + .bind(body)
53 + .bind(sort_order)
54 + .fetch_one(pool)
55 + .await?;
56 +
57 + Ok(section)
58 + }
59 +
60 + /// Update a section's title, slug, and body.
61 + pub async fn update(
62 + pool: &PgPool,
63 + section_id: ItemSectionId,
64 + title: &str,
65 + slug: &str,
66 + body: &str,
67 + ) -> Result<DbItemSection> {
68 + let section = sqlx::query_as::<_, DbItemSection>(
69 + r#"
70 + UPDATE item_sections
71 + SET title = $2, slug = $3, body = $4, updated_at = now()
72 + WHERE id = $1
73 + RETURNING *
74 + "#,
75 + )
76 + .bind(section_id)
77 + .bind(title)
78 + .bind(slug)
79 + .bind(body)
80 + .fetch_one(pool)
81 + .await?;
82 +
83 + Ok(section)
84 + }
85 +
86 + /// Permanently delete a section by ID.
87 + pub async fn delete(pool: &PgPool, section_id: ItemSectionId) -> Result<()> {
88 + sqlx::query("DELETE FROM item_sections WHERE id = $1")
89 + .bind(section_id)
90 + .execute(pool)
91 + .await?;
92 +
93 + Ok(())
94 + }
95 +
96 + /// Reorder sections by setting sort_order from an ordered list of IDs.
97 + pub async fn reorder(pool: &PgPool, item_id: ItemId, section_ids: &[ItemSectionId]) -> Result<()> {
98 + for (i, id) in section_ids.iter().enumerate() {
99 + sqlx::query(
100 + "UPDATE item_sections SET sort_order = $1, updated_at = now() WHERE id = $2 AND item_id = $3",
101 + )
102 + .bind(i as i32)
103 + .bind(id)
104 + .bind(item_id)
105 + .execute(pool)
106 + .await?;
107 + }
108 +
109 + Ok(())
110 + }
111 +
112 + /// Count sections for an item.
113 + pub async fn count_by_item(pool: &PgPool, item_id: ItemId) -> Result<i64> {
114 + let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM item_sections WHERE item_id = $1")
115 + .bind(item_id)
116 + .fetch_one(pool)
117 + .await?;
118 +
119 + Ok(row.0)
120 + }
@@ -13,6 +13,7 @@ pub(crate) mod projects;
13 13 pub mod items;
14 14 pub mod versions;
15 15 pub(crate) mod chapters;
16 + pub(crate) mod item_sections;
16 17 pub mod transactions;
17 18 pub(crate) mod discover;
18 19 pub(crate) mod custom_links;
@@ -446,6 +446,27 @@ pub struct DbChapter {
446 446 pub created_at: DateTime<Utc>,
447 447 }
448 448
449 + /// A tabbed content section within an item.
450 + #[derive(Debug, Clone, FromRow, Serialize)]
451 + pub struct DbItemSection {
452 + /// Database primary key.
453 + pub id: ItemSectionId,
454 + /// Parent item ID.
455 + pub item_id: ItemId,
456 + /// Section tab title.
457 + pub title: String,
458 + /// URL-safe slug (unique per item).
459 + pub slug: String,
460 + /// Markdown body content.
461 + pub body: String,
462 + /// Display order among sibling sections.
463 + pub sort_order: i32,
464 + /// When this section was created.
465 + pub created_at: DateTime<Utc>,
466 + /// When this section was last modified.
467 + pub updated_at: DateTime<Utc>,
468 + }
469 +
449 470 /// Completed-transaction state: fields that are always present when
450 471 /// `status == Completed`.
451 472 #[derive(Debug, Clone)]
@@ -4,6 +4,7 @@ mod bulk;
4 4 mod bundles;
5 5 mod chapters;
6 6 mod crud;
7 + mod sections;
7 8 mod tags;
8 9 mod versions;
9 10
@@ -11,6 +12,7 @@ pub(super) use bulk::{bulk_delete, bulk_publish, bulk_unpublish};
11 12 pub use bundles::{bundle_add, bundle_remove, bundle_toggle_listed};
12 13 pub(super) use chapters::{create_chapter, delete_chapter, list_chapters, update_chapter};
13 14 pub(super) use crud::{create_item, delete_item, duplicate_item, move_item, update_item};
15 + pub(super) use sections::{create_section, delete_section, list_sections, reorder_sections, update_section};
14 16 pub(super) use tags::{add_tag, remove_tag, set_primary_tag};
15 17 pub(super) use crud::update_item_text;
16 18 pub(super) use versions::{create_version, list_versions};
@@ -0,0 +1,207 @@
1 + //! Item section handlers: tabbed markdown content blocks.
2 +
3 + use axum::{
4 + extract::{Path, State},
5 + http::{header::HeaderMap, StatusCode},
6 + response::{IntoResponse, Response},
7 + Json,
8 + };
9 + use serde::{Deserialize, Serialize};
10 +
11 + use crate::{
12 + auth::AuthUser,
13 + db::{self, ItemId, ItemSectionId},
14 + error::{AppError, Result},
15 + helpers::{htmx_toast_response, is_htmx_request, slugify},
16 + types::ListResponse,
17 + validation,
18 + AppState,
19 + };
20 +
21 + use super::super::verify_item_ownership;
22 +
23 + /// Maximum number of sections per item.
24 + const MAX_SECTIONS_PER_ITEM: i64 = 10;
25 +
26 + /// JSON input for creating a section.
27 + #[derive(Debug, Deserialize)]
28 + pub struct CreateSectionRequest {
29 + pub title: String,
30 + #[serde(default)]
31 + pub body: String,
32 + }
33 +
34 + /// JSON input for updating a section.
35 + #[derive(Debug, Deserialize)]
36 + pub struct UpdateSectionRequest {
37 + pub title: String,
38 + #[serde(default)]
39 + pub body: String,
40 + }
41 +
42 + /// JSON input for reordering sections.
43 + #[derive(Debug, Deserialize)]
44 + pub struct ReorderSectionsRequest {
45 + pub section_ids: Vec<ItemSectionId>,
46 + }
47 +
48 + /// JSON response representing a section.
49 + #[derive(Debug, Serialize)]
50 + struct SectionResponse {
51 + id: ItemSectionId,
52 + item_id: ItemId,
53 + title: String,
54 + slug: String,
55 + body: String,
56 + sort_order: i32,
57 + }
58 +
59 + impl From<db::DbItemSection> for SectionResponse {
60 + fn from(s: db::DbItemSection) -> Self {
61 + Self {
62 + id: s.id,
63 + item_id: s.item_id,
64 + title: s.title,
65 + slug: s.slug,
66 + body: s.body,
67 + sort_order: s.sort_order,
68 + }
69 + }
70 + }
71 +
72 + /// Create a new section on an owned item.
73 + #[tracing::instrument(skip_all, name = "items::create_section")]
74 + pub(in crate::routes::api) async fn create_section(
75 + State(state): State<AppState>,
76 + AuthUser(user): AuthUser,
77 + Path(item_id): Path<ItemId>,
78 + Json(req): Json<CreateSectionRequest>,
79 + ) -> Result<impl IntoResponse> {
80 + user.check_not_suspended()?;
81 + let title = req.title.trim().to_string();
82 + validation::validate_section_title(&title)?;
83 + validation::validate_section_body(&req.body)?;
84 +
85 + let (item, _) = verify_item_ownership(&state, item_id, user.id).await?;
86 +
87 + // Enforce max sections limit
88 + let count = db::item_sections::count_by_item(&state.db, item_id).await?;
89 + if count >= MAX_SECTIONS_PER_ITEM {
90 + return Err(AppError::Validation(format!(
91 + "Maximum of {} sections per item",
92 + MAX_SECTIONS_PER_ITEM
93 + )));
94 + }
95 +
96 + let slug = slugify(&title).to_string();
97 + let sort_order = count as i32;
98 +
99 + let section = db::item_sections::create(
100 + &state.db,
101 + item_id,
102 + &title,
103 + &slug,
104 + &req.body,
105 + sort_order,
106 + )
107 + .await?;
108 +
109 + db::projects::bump_cache_generation(&state.db, item.project_id).await?;
110 +
111 + Ok(Json(SectionResponse::from(section)))
112 + }
113 +
114 + /// List all sections for a given item (public items only; drafts return 404).
115 + #[tracing::instrument(skip_all, name = "items::list_sections")]
116 + pub(in crate::routes::api) async fn list_sections(
117 + State(state): State<AppState>,
118 + Path(item_id): Path<ItemId>,
119 + ) -> Result<impl IntoResponse> {
120 + let item = db::items::get_item_by_id(&state.db, item_id)
121 + .await?
122 + .ok_or(AppError::NotFound)?;
123 + if !item.is_public {
124 + return Err(AppError::NotFound);
125 + }
126 + let sections = db::item_sections::list_by_item(&state.db, item_id).await?;
127 + let data: Vec<SectionResponse> = sections.into_iter().map(SectionResponse::from).collect();
128 + Ok(Json(ListResponse { data }))
129 + }
130 +
131 + /// Update an existing section on an owned item.
132 + #[tracing::instrument(skip_all, name = "items::update_section")]
133 + pub(in crate::routes::api) async fn update_section(
134 + State(state): State<AppState>,
135 + AuthUser(user): AuthUser,
136 + Path(section_id): Path<ItemSectionId>,
137 + Json(req): Json<UpdateSectionRequest>,
138 + ) -> Result<impl IntoResponse> {
139 + user.check_not_suspended()?;
140 + let title = req.title.trim().to_string();
141 + validation::validate_section_title(&title)?;
142 + validation::validate_section_body(&req.body)?;
143 +
144 + let section = db::item_sections::get_by_id(&state.db, section_id)
145 + .await?
146 + .ok_or(AppError::NotFound)?;
147 +
148 + let (item, _) = verify_item_ownership(&state, section.item_id, user.id).await?;
149 +
150 + let slug = slugify(&title).to_string();
151 +
152 + let updated = db::item_sections::update(
153 + &state.db,
154 + section_id,
155 + &title,
156 + &slug,
157 + &req.body,
158 + )
159 + .await?;
160 +
161 + db::projects::bump_cache_generation(&state.db, item.project_id).await?;
162 +
163 + Ok(Json(SectionResponse::from(updated)))
164 + }
165 +
166 + /// Delete a section from an owned item.
167 + #[tracing::instrument(skip_all, name = "items::delete_section")]
168 + pub(in crate::routes::api) async fn delete_section(
169 + State(state): State<AppState>,
170 + headers: HeaderMap,
171 + AuthUser(user): AuthUser,
172 + Path(section_id): Path<ItemSectionId>,
173 + ) -> Result<Response> {
174 + user.check_not_suspended()?;
175 +
176 + let section = db::item_sections::get_by_id(&state.db, section_id)
177 + .await?
178 + .ok_or(AppError::NotFound)?;
179 +
180 + let (item, _) = verify_item_ownership(&state, section.item_id, user.id).await?;
181 +
182 + db::item_sections::delete(&state.db, section_id).await?;
183 + db::projects::bump_cache_generation(&state.db, item.project_id).await?;
184 +
185 + if is_htmx_request(&headers) {
186 + return Ok(htmx_toast_response("Section deleted", "success").into_response());
187 + }
188 +
189 + Ok(StatusCode::NO_CONTENT.into_response())
190 + }
191 +
192 + /// Reorder sections for an owned item.
193 + #[tracing::instrument(skip_all, name = "items::reorder_sections")]
194 + pub(in crate::routes::api) async fn reorder_sections(
195 + State(state): State<AppState>,
196 + AuthUser(user): AuthUser,
197 + Path(item_id): Path<ItemId>,
198 + Json(req): Json<ReorderSectionsRequest>,
199 + ) -> Result<impl IntoResponse> {
200 + user.check_not_suspended()?;
201 + let (item, _) = verify_item_ownership(&state, item_id, user.id).await?;
202 +
203 + db::item_sections::reorder(&state.db, item_id, &req.section_ids).await?;
204 + db::projects::bump_cache_generation(&state.db, item.project_id).await?;
205 +
206 + Ok(StatusCode::NO_CONTENT)
207 + }
@@ -239,6 +239,11 @@ pub fn api_routes() -> Router<AppState> {
239 239 .route("/api/items/{id}/chapters", post(items::create_chapter))
240 240 .route("/api/chapters/{id}", put(items::update_chapter))
241 241 .route("/api/chapters/{id}", delete(items::delete_chapter))
242 + // Section routes
243 + .route("/api/items/{id}/sections", post(items::create_section))
244 + .route("/api/sections/{id}", put(items::update_section))
245 + .route("/api/sections/{id}", delete(items::delete_section))
246 + .route("/api/items/{id}/sections/reorder", put(items::reorder_sections))
242 247 // Library routes
243 248 .route("/api/library/add/{item_id}", post(users::add_to_library))
244 249 .route("/api/library/remove/{item_id}", delete(users::remove_from_library))
@@ -353,6 +358,7 @@ pub fn api_routes() -> Router<AppState> {
353 358 .route("/api/projects", get(projects::list_projects))
354 359 .route("/api/items/{id}/versions", get(items::list_versions))
355 360 .route("/api/items/{id}/chapters", get(items::list_chapters))
361 + .route("/api/items/{id}/sections", get(items::list_sections))
356 362 .route("/api/projects/{id}/blog", get(blog::list_blog_posts))
357 363 .route("/api/blog/{id}", get(blog::get_blog_post))
358 364 .route("/api/tags/search", get(tags::search_tags))
@@ -93,10 +93,15 @@ pub(in crate::routes::pages::dashboard) async fn item_tab_details(
93 93 (vec![], vec![])
94 94 };
95 95
96 + let db_sections = db::item_sections::list_by_item(&state.db, item_id).await?;
97 + let sections: Vec<crate::types::ItemSection> =
98 + db_sections.iter().map(crate::types::ItemSection::from).collect();
99 +
96 100 Ok(ItemDetailsTabTemplate {
97 101 item,
98 102 bundle_items,
99 103 bundleable_items,
104 + sections,
100 105 })
101 106 }
102 107
@@ -42,6 +42,7 @@ pub const ITEM_STEPS: &[&str] = &[
42 42 "details",
43 43 "appearance",
44 44 "content",
45 + "sections",
45 46 "pricing",
46 47 "distribution",
47 48 "preview",
@@ -53,6 +54,7 @@ pub(super) const ITEM_LABELS: &[&str] = &[
53 54 "Details",
54 55 "Appearance",
55 56 "Content",
57 + "Sections",
56 58 "Pricing",
57 59 "Distribution",
58 60 "Preview",
@@ -255,6 +257,7 @@ pub async fn step_save(
255 257 "details" => save::save_details(&state, &item, &form).await?,
256 258 "appearance" => save::save_appearance(&state, &item, &form).await?,
257 259 "content" => save::save_content(&state, &item, &form).await?,
260 + "sections" => {} // Sections managed via HTMX API; pass-through
258 261 "pricing" => save::save_pricing(&state, &item, &form).await?,
259 262 "distribution" => save::save_distribution(&state, &item, &form).await?,
260 263 "preview" => return save::save_preview(&state, &user, &project, &item, &form).await,
@@ -117,6 +117,19 @@ pub(super) async fn render_step(
117 117 .into_response())
118 118 }
119 119
120 + "sections" => {
121 + let db_sections = db::item_sections::list_by_item(&state.db, item.id).await?;
122 + let sections: Vec<crate::types::ItemSection> =
123 + db_sections.iter().map(crate::types::ItemSection::from).collect();
124 + Ok(WizardItemSectionsTemplate {
125 + nav,
126 + project_slug,
127 + item_id,
128 + sections,
129 + }
130 + .into_response())
131 + }
132 +
120 133 "pricing" => {
121 134 let pricing_model = if item.pwyw_enabled {
122 135 "pwyw"
@@ -255,6 +255,9 @@ pub(crate) async fn render_item_page(
255 255 })
256 256 .collect();
257 257
258 + let db_sections = db::item_sections::list_by_item(&state.db, db_item.id).await?;
259 + let sections: Vec<ItemSection> = db_sections.iter().map(ItemSection::from).collect();
260 +
258 261 Ok(ItemTemplate {
259 262 csrf_token,
260 263 session_user: maybe_user,
@@ -269,6 +272,7 @@ pub(crate) async fn render_item_page(
269 272 discussion_count,
270 273 bundle_items: bundle_item_views,
271 274 containing_bundles: containing_bundle_views,
275 + sections,
272 276 }
273 277 .into_response())
274 278 }
@@ -364,6 +364,15 @@ pub struct BundleableItem {
364 364 }
365 365
366 366 #[derive(Template)]
367 + #[template(path = "wizards/steps/item/sections.html")]
368 + pub struct WizardItemSectionsTemplate {
369 + pub nav: Vec<StepNavItem>,
370 + pub project_slug: String,
371 + pub item_id: String,
372 + pub sections: Vec<crate::types::ItemSection>,
373 + }
374 +
375 + #[derive(Template)]
367 376 #[template(path = "wizards/steps/item/pricing.html")]
368 377 pub struct WizardItemPricingTemplate {
369 378 pub nav: Vec<StepNavItem>,
@@ -209,6 +209,7 @@ impl_into_response!(
209 209 WizardItemDetailsTemplate,
210 210 WizardItemAppearanceTemplate,
211 211 WizardItemContentTemplate,
212 + WizardItemSectionsTemplate,
212 213 WizardItemPricingTemplate,
213 214 WizardItemDistributionTemplate,
214 215 WizardItemPreviewTemplate,
@@ -702,13 +702,14 @@ pub struct ItemOverviewTabTemplate {
702 702 pub item: Item,
703 703 }
704 704
705 - /// Item details tab: name, description, tags, content editor, bundle contents.
705 + /// Item details tab: name, description, tags, content editor, bundle contents, sections.
706 706 #[derive(Template)]
707 707 #[template(path = "partials/tabs/item_details.html")]
708 708 pub struct ItemDetailsTabTemplate {
709 709 pub item: Item,
710 710 pub bundle_items: Vec<Item>,
711 711 pub bundleable_items: Vec<Item>,
712 + pub sections: Vec<ItemSection>,
712 713 }
713 714
714 715 /// Item pricing tab: PWYW settings, license keys, promo codes.
@@ -253,6 +253,8 @@ pub struct ItemTemplate {
253 253 pub bundle_items: Vec<Item>,
254 254 /// Bundles containing this item (for unlisted items, to show "Available in" links).
255 255 pub containing_bundles: Vec<Item>,
256 + /// Tabbed content sections (e.g. Features, Installation, Specs).
257 + pub sections: Vec<ItemSection>,
256 258 }
257 259
258 260 /// Blog/article reader view.
@@ -178,6 +178,21 @@ impl From<&db::DbBlogPost> for BlogPostSummary {
178 178 }
179 179 }
180 180
181 + /// Convert an item section database row into a template `ItemSection`,
182 + /// rendering the markdown body to HTML.
183 + impl From<&db::DbItemSection> for ItemSection {
184 + fn from(s: &db::DbItemSection) -> Self {
185 + ItemSection {
186 + id: s.id.to_string(),
187 + title: s.title.clone(),
188 + slug: s.slug.clone(),
189 + body: s.body.clone(),
190 + body_html: docengine::render_permissive(&s.body),
191 + sort_order: s.sort_order,
192 + }
193 + }
194 + }
195 +
181 196 /// Convert a chapter database row into a template `Chapter`, computing the
182 197 /// `MM:SS` timestamp string from raw seconds.
183 198 impl From<&db::DbChapter> for Chapter {
@@ -239,6 +239,17 @@ pub struct ChartBar {
239 239 pub count: i64,
240 240 }
241 241
242 + /// A tabbed content section within an item, with pre-rendered HTML.
243 + #[derive(Clone)]
244 + pub struct ItemSection {
245 + pub id: String,
246 + pub title: String,
247 + pub slug: String,
248 + pub body: String,
249 + pub body_html: String,
250 + pub sort_order: i32,
251 + }
252 +
242 253 /// Chapter/timestamp for audio content
243 254 #[derive(Clone)]
244 255 pub struct Chapter {