Skip to main content

max / makenotwork

v0.3.15: Move collections to library, HTMX tabbed library, Stripe tax toggle, item wizard improvements Redesign /library as HTMX-tabbed shell with four tabs (Purchases, Subscriptions, Collections, Contacts) matching the dashboard tab pattern. Move collections out of the account dashboard into the library where they belong as consumer-facing content. Fix collection links to use /c/{username}/{slug} instead of /c/{id}. Add Stripe tax toggle, storage and item wizard enhancements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-29 05:25 UTC
Commit: 9185090cc2649239df517273ef5ad152ed0c293b
Parent: 3c3bdd7
36 files changed, +896 insertions, -344 deletions
@@ -3350,7 +3350,7 @@ dependencies = [
3350 3350
3351 3351 [[package]]
3352 3352 name = "makenotwork"
3353 - version = "0.3.13"
3353 + version = "0.3.14"
3354 3354 dependencies = [
3355 3355 "anyhow",
3356 3356 "argon2",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.3.14"
3 + version = "0.3.15"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -0,0 +1 @@
1 + ALTER TABLE users ADD COLUMN stripe_tax_enabled BOOLEAN NOT NULL DEFAULT false;
@@ -728,6 +728,23 @@ pub async fn update_item_audio_file_size(
728 728 Ok(())
729 729 }
730 730
731 + /// Update the cover image URL for an item (permanent/CDN URL for display).
732 + pub async fn update_item_cover_image_url(
733 + pool: &PgPool,
734 + item_id: ItemId,
735 + url: &str,
736 + ) -> Result<()> {
737 + sqlx::query(
738 + "UPDATE items SET cover_image_url = $2, updated_at = NOW() WHERE id = $1",
739 + )
740 + .bind(item_id)
741 + .bind(url)
742 + .execute(pool)
743 + .await?;
744 +
745 + Ok(())
746 + }
747 +
731 748 /// Update the cover file size on an item.
732 749 pub async fn update_item_cover_file_size(
733 750 pool: &PgPool,
@@ -83,6 +83,8 @@ pub struct DbUser {
83 83 pub stripe_payouts_enabled: bool,
84 84 /// Whether Stripe charges (payments) are enabled. Checked independently in checkout routes.
85 85 pub stripe_charges_enabled: bool,
86 + /// Whether the creator has opted in to Stripe Tax (automatic tax calculation at checkout).
87 + pub stripe_tax_enabled: bool,
86 88 // Email verification
87 89 /// Whether the user's email address has been verified.
88 90 pub email_verified: bool,
@@ -1711,6 +1713,7 @@ mod tests {
1711 1713 stripe_onboarding_complete: onboarding,
1712 1714 stripe_payouts_enabled: payouts,
1713 1715 stripe_charges_enabled: false,
1716 + stripe_tax_enabled: false,
1714 1717 email_verified: false,
1715 1718 email_verification_token: None,
1716 1719 email_verification_sent_at: None,
@@ -587,6 +587,24 @@ pub async fn batch_advance_onboarding_step(
587 587 Ok(())
588 588 }
589 589
590 + /// Update a user's Stripe Tax toggle.
591 + pub async fn update_stripe_tax_enabled(pool: &PgPool, user_id: UserId, enabled: bool) -> Result<()> {
592 + sqlx::query(
593 + r#"
594 + UPDATE users
595 + SET stripe_tax_enabled = $2,
596 + updated_at = NOW()
597 + WHERE id = $1
598 + "#,
599 + )
600 + .bind(user_id)
601 + .bind(enabled)
602 + .execute(pool)
603 + .await?;
604 +
605 + Ok(())
606 + }
607 +
590 608 /// Disconnect a user's Stripe account
591 609 pub async fn disconnect_user_stripe(pool: &PgPool, user_id: UserId) -> Result<DbUser> {
592 610 let user = sqlx::query_as::<_, DbUser>(
@@ -44,6 +44,7 @@ pub struct CheckoutParams<'a> {
44 44 pub success_url: &'a str,
45 45 pub cancel_url: &'a str,
46 46 pub promo_code_id: Option<PromoCodeId>,
47 + pub enable_stripe_tax: bool,
47 48 }
48 49
49 50 /// Parameters for creating a subscription Checkout Session.
@@ -57,6 +58,7 @@ pub struct SubscriptionCheckoutParams<'a> {
57 58 pub cancel_url: &'a str,
58 59 pub trial_days: Option<i32>,
59 60 pub promo_code_id: Option<PromoCodeId>,
61 + pub enable_stripe_tax: bool,
60 62 }
61 63
62 64 /// Stripe client wrapper for payment operations
@@ -162,6 +164,13 @@ impl StripeClient {
162 164 }
163 165 params.metadata = Some(metadata);
164 166
167 + if checkout.enable_stripe_tax {
168 + params.automatic_tax = Some(stripe::CreateCheckoutSessionAutomaticTax {
169 + enabled: true,
170 + liability: None,
171 + });
172 + }
173 +
165 174 // Direct Charges: the checkout session is created on the connected
166 175 // account (the creator's Stripe), so all funds go directly to them.
167 176 // We intentionally omit `application_fee_amount` — this is the core
@@ -336,6 +345,13 @@ impl StripeClient {
336 345 }
337 346 params.metadata = Some(metadata);
338 347
348 + if sub.enable_stripe_tax {
349 + params.automatic_tax = Some(stripe::CreateCheckoutSessionAutomaticTax {
350 + enabled: true,
351 + liability: None,
352 + });
353 + }
354 +
339 355 // Apply free trial period if specified
340 356 if let Some(days) = sub.trial_days {
341 357 params.subscription_data = Some(stripe::CreateCheckoutSessionSubscriptionData {
@@ -168,6 +168,7 @@ pub fn api_routes() -> Router<AppState> {
168 168 .route("/api/users/me/password", put(users::update_password))
169 169 .route("/api/users/me/preferences", put(users::update_preferences))
170 170 .route("/api/users/me/stripe", delete(users::disconnect_stripe))
171 + .route("/api/users/me/stripe-tax", put(users::toggle_stripe_tax))
171 172 .route("/api/users/me", delete(users::delete_account))
172 173 // Broadcast
173 174 .route("/api/broadcast", post(users::broadcast_send))
@@ -7,6 +7,7 @@ mod library;
7 7 mod notifications;
8 8 mod profile;
9 9 mod sessions;
10 + mod stripe_tax;
10 11 mod waitlist;
11 12
12 13 pub(super) use broadcast::broadcast_send;
@@ -19,6 +20,7 @@ pub(super) use profile::{
19 20 submit_appeal, update_password, update_profile,
20 21 };
21 22 pub(super) use sessions::{revoke_other_sessions, revoke_session};
23 + pub(super) use stripe_tax::toggle_stripe_tax;
22 24 pub(super) use waitlist::waitlist_apply;
23 25
24 26 use serde::Serialize;
@@ -0,0 +1,38 @@
1 + //! Stripe Tax toggle for creator accounts.
2 +
3 + use axum::{
4 + extract::State,
5 + response::{Html, IntoResponse, Response},
6 + Form,
7 + };
8 + use serde::Deserialize;
9 +
10 + use crate::{
11 + auth::AuthUser,
12 + db,
13 + error::Result,
14 + templates::SaveStatusTemplate,
15 + AppState,
16 + };
17 +
18 + #[derive(Debug, Deserialize)]
19 + pub struct StripeTaxForm {
20 + pub stripe_tax_enabled: Option<String>,
21 + }
22 +
23 + /// Toggle Stripe Tax for the authenticated creator.
24 + #[tracing::instrument(skip_all, name = "users::toggle_stripe_tax")]
25 + pub(in crate::routes::api) async fn toggle_stripe_tax(
26 + State(state): State<AppState>,
27 + AuthUser(user): AuthUser,
28 + Form(form): Form<StripeTaxForm>,
29 + ) -> Result<Response> {
30 + let enabled = form.stripe_tax_enabled.as_deref() == Some("on");
31 +
32 + db::users::update_stripe_tax_enabled(&state.db, user.id, enabled).await?;
33 +
34 + Ok(Html(SaveStatusTemplate {
35 + success: true,
36 + message: "Tax setting saved".to_string(),
37 + }.render_string()).into_response())
38 + }
@@ -50,7 +50,6 @@ pub fn dashboard_routes() -> Router<AppState> {
50 50 .route("/dashboard/tabs/analytics", get(tabs::dashboard_tab_analytics))
51 51 .route("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit))
52 52 .route("/dashboard/tabs/forums", get(tabs::dashboard_tab_forums))
53 - .route("/dashboard/tabs/collections", get(tabs::dashboard_tab_collections))
54 53 .route("/dashboard/transactions", get(tabs::dashboard_transactions))
55 54 .route("/dashboard/project/{slug}/tabs/overview", get(project_tabs::project_tab_overview))
56 55 .route("/dashboard/project/{slug}/tabs/content", get(project_tabs::project_tab_content))
@@ -517,21 +517,4 @@ pub(super) async fn dashboard_tab_synckit(
517 517 Ok(UserSyncKitTabTemplate { apps, projects })
518 518 }
519 519
520 - /// Render the HTMX partial for the dashboard collections tab.
521 - #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_collections")]
522 - pub(super) async fn dashboard_tab_collections(
523 - State(state): State<AppState>,
524 - session: Session,
525 - AuthUser(session_user): AuthUser,
526 - ) -> Result<impl IntoResponse> {
527 - let csrf_token = helpers::get_csrf_token(&session).await;
528 -
529 - let db_collections = db::collections::get_collections_by_user(&state.db, session_user.id).await?;
530 - let collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect();
531 -
532 - Ok(UserCollectionsTabTemplate {
533 - csrf_token,
534 - collections,
535 - })
536 - }
537 520
@@ -26,6 +26,7 @@ use super::build_step_nav;
26 26 pub const ITEM_STEPS: &[&str] = &[
27 27 "type",
28 28 "details",
29 + "appearance",
29 30 "content",
30 31 "pricing",
31 32 "distribution",
@@ -36,6 +37,7 @@ pub const ITEM_STEPS: &[&str] = &[
36 37 const ITEM_LABELS: &[&str] = &[
37 38 "Type",
38 39 "Details",
40 + "Appearance",
39 41 "Content",
40 42 "Pricing",
41 43 "Distribution",
@@ -237,6 +239,7 @@ pub async fn step_save(
237 239 match step.as_str() {
238 240 "type" => save_type(&state, &project, &item, &form).await?,
239 241 "details" => save_details(&state, &item, &form).await?,
242 + "appearance" => save_appearance(&state, &item, &form).await?,
240 243 "content" => save_content(&state, &item, &form).await?,
241 244 "pricing" => save_pricing(&state, &item, &form).await?,
242 245 "distribution" => save_distribution(&state, &item, &form).await?,
@@ -329,6 +332,21 @@ async fn save_details(
329 332 Ok(())
330 333 }
331 334
335 + async fn save_appearance(
336 + state: &AppState,
337 + item: &db::DbItem,
338 + form: &HashMap<String, String>,
339 + ) -> Result<()> {
340 + // The cover_image_url is set via the JS upload flow (presign → S3 PUT → confirm).
341 + // The hidden field carries the URL so we can store it if the user uploaded one.
342 + if let Some(url) = form.get("cover_image_url")
343 + && !url.is_empty()
344 + {
345 + db::items::update_item_cover_image_url(&state.db, item.id, url).await?;
346 + }
347 + Ok(())
348 + }
349 +
332 350 async fn save_content(
333 351 state: &AppState,
334 352 item: &db::DbItem,
@@ -602,6 +620,14 @@ async fn render_step(
602 620 }
603 621 .into_response()),
604 622
623 + "appearance" => Ok(WizardItemAppearanceTemplate {
624 + nav,
625 + project_slug,
626 + item_id,
627 + cover_image_url: item.cover_image_url.clone(),
628 + }
629 + .into_response()),
630 +
605 631 "content" => {
606 632 let content_template = item.item_type.to_string();
607 633
@@ -501,6 +501,7 @@ pub(super) async fn purchase_page(
501 501 pwyw_min_cents: pwyw_min,
502 502 suggested_price,
503 503 pwyw_min_dollars,
504 + stripe_tax_enabled: db_user.stripe_tax_enabled,
504 505 })
505 506 }
506 507
@@ -46,26 +46,63 @@ pub(super) async fn index(
46 46 }
47 47 }
48 48
49 - /// Render the authenticated user's library of purchased and claimed items.
49 + /// Render the authenticated user's library shell (tab content loaded via HTMX).
50 50 #[tracing::instrument(skip_all, name = "landing::library")]
51 51 pub(super) async fn library(
52 - State(state): State<AppState>,
53 52 session: Session,
54 53 AuthUser(user): AuthUser,
55 54 ) -> Result<impl IntoResponse> {
56 - let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?;
57 - let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?;
58 - let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
59 - let shared_creators = db::transactions::get_shared_creators(&state.db, user.id).await?;
60 55 Ok(LibraryTemplate {
61 56 csrf_token: get_csrf_token(&session).await,
62 57 session_user: Some(user),
63 - purchases,
64 - subscriptions,
65 - shared_creators,
66 58 })
67 59 }
68 60
61 + /// HTMX partial: library purchases tab.
62 + #[tracing::instrument(skip_all, name = "landing::library_tab_purchases")]
63 + pub(super) async fn library_tab_purchases(
64 + State(state): State<AppState>,
65 + AuthUser(user): AuthUser,
66 + ) -> Result<impl IntoResponse> {
67 + let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?;
68 + Ok(LibraryPurchasesTabTemplate { purchases })
69 + }
70 +
71 + /// HTMX partial: library subscriptions tab.
72 + #[tracing::instrument(skip_all, name = "landing::library_tab_subscriptions")]
73 + pub(super) async fn library_tab_subscriptions(
74 + State(state): State<AppState>,
75 + AuthUser(user): AuthUser,
76 + ) -> Result<impl IntoResponse> {
77 + let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?;
78 + let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
79 + Ok(LibrarySubscriptionsTabTemplate { subscriptions })
80 + }
81 +
82 + /// HTMX partial: library collections tab.
83 + #[tracing::instrument(skip_all, name = "landing::library_tab_collections")]
84 + pub(super) async fn library_tab_collections(
85 + State(state): State<AppState>,
86 + AuthUser(user): AuthUser,
87 + ) -> Result<impl IntoResponse> {
88 + let db_collections = db::collections::get_collections_by_user(&state.db, user.id).await?;
89 + let collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect();
90 + Ok(LibraryCollectionsTabTemplate {
91 + collections,
92 + username: user.username.to_string(),
93 + })
94 + }
95 +
96 + /// HTMX partial: library contacts tab.
97 + #[tracing::instrument(skip_all, name = "landing::library_tab_contacts")]
98 + pub(super) async fn library_tab_contacts(
99 + State(state): State<AppState>,
100 + AuthUser(user): AuthUser,
101 + ) -> Result<impl IntoResponse> {
102 + let shared_creators = db::transactions::get_shared_creators(&state.db, user.id).await?;
103 + Ok(LibraryContactsTabTemplate { shared_creators })
104 + }
105 +
69 106 /// Render the login page.
70 107 #[tracing::instrument(skip_all, name = "landing::login_page")]
71 108 pub(super) async fn login_page(session: Session) -> impl IntoResponse {
@@ -38,6 +38,10 @@ pub fn public_routes() -> Router<AppState> {
38 38 Router::new()
39 39 .route("/", get(landing::index))
40 40 .route("/library", get(landing::library))
41 + .route("/library/tabs/purchases", get(landing::library_tab_purchases))
42 + .route("/library/tabs/subscriptions", get(landing::library_tab_subscriptions))
43 + .route("/library/tabs/collections", get(landing::library_tab_collections))
44 + .route("/library/tabs/contacts", get(landing::library_tab_contacts))
41 45 .route("/health", get(health::health))
42 46 .route("/api/health", get(health::health_json))
43 47 .route("/login", get(landing::login_page))
@@ -33,6 +33,8 @@ pub fn storage_routes() -> Router<AppState> {
33 33 .route("/api/versions/{version_id}/upload/confirm", post(version_confirm_upload))
34 34 .route("/api/projects/image/presign", post(project_image_presign))
35 35 .route("/api/projects/image/confirm", post(project_image_confirm))
36 + .route("/api/items/image/presign", post(item_image_presign))
37 + .route("/api/items/image/confirm", post(item_image_confirm))
36 38 .route_layer(GovernorLayer {
37 39 config: upload_rate_limit,
38 40 });
@@ -133,6 +135,21 @@ pub struct ProjectImageConfirmResponse {
133 135 pub image_url: String,
134 136 }
135 137
138 + /// JSON input for requesting a presigned item image upload URL.
139 + #[derive(Debug, Deserialize)]
140 + pub struct ItemImagePresignRequest {
141 + pub item_id: ItemId,
142 + pub file_name: String,
143 + pub content_type: String,
144 + }
145 +
146 + /// JSON input for confirming a completed item image upload.
147 + #[derive(Debug, Deserialize)]
148 + pub struct ItemImageConfirmRequest {
149 + pub item_id: ItemId,
150 + pub s3_key: String,
151 + }
152 +
136 153 // =============================================================================
137 154 // Helpers
138 155 // =============================================================================
@@ -757,3 +774,131 @@ async fn project_image_confirm(
757 774 image_url,
758 775 }))
759 776 }
777 +
778 + /// Generate a presigned URL for uploading an item image (logo/cover)
779 + ///
780 + /// POST /api/items/image/presign
781 + ///
782 + /// Requires authentication. User must own the item.
783 + #[tracing::instrument(skip_all, name = "storage::item_image_presign")]
784 + async fn item_image_presign(
785 + State(state): State<AppState>,
786 + AuthUser(user): AuthUser,
787 + Json(req): Json<ItemImagePresignRequest>,
788 + ) -> Result<impl IntoResponse> {
789 + user.check_not_suspended()?;
790 + let s3 = state.require_s3()?;
791 +
792 + let file_type = FileType::Cover;
793 + S3Client::validate_content_type(file_type, &req.content_type)?;
794 + S3Client::validate_extension(file_type, &req.file_name)?;
795 +
796 + // Verify user owns the item
797 + let owner = db::items::get_item_owner(&state.db, req.item_id)
798 + .await?
799 + .ok_or(AppError::NotFound)?;
800 +
801 + if owner != user.id {
802 + return Err(AppError::Forbidden);
803 + }
804 +
805 + let s3_key = S3Client::generate_key(user.id, req.item_id, file_type, &req.file_name);
806 + let expires_in = 3600;
807 + let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE)).await?;
808 +
809 + Ok(Json(PresignUploadResponse {
810 + upload_url,
811 + s3_key,
812 + expires_in,
813 + cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
814 + }))
815 + }
816 +
817 + /// Confirm an item image upload, scan, store URL
818 + ///
819 + /// POST /api/items/image/confirm
820 + ///
821 + /// Requires authentication. User must own the item.
822 + #[tracing::instrument(skip_all, name = "storage::item_image_confirm")]
823 + async fn item_image_confirm(
824 + State(state): State<AppState>,
825 + AuthUser(user): AuthUser,
826 + Json(req): Json<ItemImageConfirmRequest>,
827 + ) -> Result<impl IntoResponse> {
828 + user.check_not_suspended()?;
829 + let s3 = state.require_s3()?;
830 +
831 + // Verify user owns the item
832 + let owner = db::items::get_item_owner(&state.db, req.item_id)
833 + .await?
834 + .ok_or(AppError::NotFound)?;
835 +
836 + if owner != user.id {
837 + return Err(AppError::Forbidden);
838 + }
839 +
840 + // Verify the object exists in S3
841 + if !s3.object_exists(&req.s3_key).await? {
842 + return Err(AppError::BadRequest(
843 + "Upload not found. Please try uploading again.".to_string(),
844 + ));
845 + }
846 +
847 + // Enforce file size limit
848 + let file_size_bytes = s3.object_size(&req.s3_key).await?.unwrap_or(0);
849 + if file_size_bytes as u64 > FileType::Cover.max_size() {
850 + s3.delete_object(&req.s3_key).await.ok();
851 + return Err(AppError::BadRequest(format!(
852 + "File exceeds maximum size of {} MB",
853 + FileType::Cover.max_size() / (1024 * 1024)
854 + )));
855 + }
856 +
857 + // Enforce tier-based limits
858 + let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Cover, file_size_bytes).await {
859 + Ok(max) => max,
860 + Err(e) => {
861 + s3.delete_object(&req.s3_key).await.ok();
862 + return Err(e);
863 + }
864 + };
865 +
866 + // Scan + classify
867 + let (status, malware_err) = scan_and_classify(&state, s3.as_ref(), &req.s3_key, FileType::Cover, user.id).await?;
868 + db::scanning::update_item_scan_status(&state.db, req.item_id, status).await?;
869 + if let Some(err) = malware_err {
870 + return Err(err);
871 + }
872 +
873 + // Build permanent URL (CDN or presigned)
874 + let image_url = storage::build_project_image_url(
875 + s3.as_ref(),
876 + state.config.cdn_base_url.as_deref(),
877 + &req.s3_key,
878 + ).await?;
879 +
880 + // Store URL, S3 key, and file size
881 + db::items::update_item_cover_image_url(&state.db, req.item_id, &image_url).await?;
882 + db::items::update_item_cover_s3_key(&state.db, req.item_id, &req.s3_key).await?;
883 + db::items::update_item_cover_file_size(&state.db, req.item_id, file_size_bytes).await?;
884 +
885 + // Atomically increment storage
886 + db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await?;
887 +
888 + // Bump project cache
889 + if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await? {
890 + let _ = db::projects::bump_cache_generation(&state.db, item.project_id).await;
891 + }
892 +
893 + tracing::info!(
894 + "Item image confirmed: item={}, key={}, size={}",
895 + req.item_id,
896 + req.s3_key,
897 + file_size_bytes
898 + );
899 +
900 + Ok(Json(ProjectImageConfirmResponse {
901 + success: true,
902 + image_url,
903 + }))
904 + }
@@ -311,6 +311,7 @@ pub(super) async fn create_checkout(
311 311 success_url: &success_url,
312 312 cancel_url: &cancel_url,
313 313 promo_code_id,
314 + enable_stripe_tax: seller.stripe_tax_enabled,
314 315 };
315 316 let session = stripe.create_checkout_session(&checkout_params).await?;
316 317
@@ -541,6 +542,7 @@ pub(super) async fn create_subscription_checkout(
541 542 cancel_url: &cancel_url,
542 543 trial_days,
543 544 promo_code_id,
545 + enable_stripe_tax: creator.stripe_tax_enabled,
544 546 },
545 547 ).await?;
546 548
@@ -705,6 +707,7 @@ pub(super) async fn create_project_checkout(
705 707 success_url: &success_url,
706 708 cancel_url: &cancel_url,
707 709 promo_code_id: None,
710 + enable_stripe_tax: seller.stripe_tax_enabled,
708 711 };
709 712 let session = stripe.create_checkout_session(&checkout_params).await?;
710 713
@@ -313,6 +313,15 @@ pub struct WizardItemDetailsTemplate {
313 313 }
314 314
315 315 #[derive(Template)]
316 + #[template(path = "wizards/steps/item/appearance.html")]
317 + pub struct WizardItemAppearanceTemplate {
318 + pub nav: Vec<StepNavItem>,
319 + pub project_slug: String,
320 + pub item_id: String,
321 + pub cover_image_url: Option<String>,
322 + }
323 +
324 + #[derive(Template)]
316 325 #[template(path = "wizards/steps/item/content.html")]
317 326 pub struct WizardItemContentTemplate {
318 327 pub nav: Vec<StepNavItem>,
@@ -142,8 +142,12 @@ impl_into_response!(
142 142 // Forums (Multithreaded)
143 143 UserForumsTabTemplate,
144 144 // Collections
145 - UserCollectionsTabTemplate,
146 145 CollectionTemplate,
146 + // Library tabs
147 + LibraryPurchasesTabTemplate,
148 + LibrarySubscriptionsTabTemplate,
149 + LibraryCollectionsTabTemplate,
150 + LibraryContactsTabTemplate,
147 151 // Follow button
148 152 FollowButtonTemplate,
149 153 TagFollowToggleTemplate,
@@ -193,6 +197,7 @@ impl_into_response!(
193 197 // Creation wizards — item step partials
194 198 WizardItemTypeTemplate,
195 199 WizardItemDetailsTemplate,
200 + WizardItemAppearanceTemplate,
196 201 WizardItemContentTemplate,
197 202 WizardItemPricingTemplate,
198 203 WizardItemDistributionTemplate,