max / makenotwork
28 files changed,
+1155 insertions,
-3 deletions
| @@ -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", |
| @@ -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 { |