max / makenotwork
24 files changed,
+880 insertions,
-40 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.3.11" | |
| 3 | + | version = "0.3.12" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "../../LICENSE" | |
| 6 | 6 |
| @@ -0,0 +1,14 @@ | |||
| 1 | + | -- Items can be unlisted (hidden from project page, accessible via bundle or direct URL) | |
| 2 | + | ALTER TABLE items ADD COLUMN listed BOOLEAN NOT NULL DEFAULT true; | |
| 3 | + | ||
| 4 | + | -- Bundle contents: which items are included in a bundle-type item | |
| 5 | + | CREATE TABLE bundle_items ( | |
| 6 | + | bundle_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, | |
| 7 | + | item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, | |
| 8 | + | sort_order INT NOT NULL DEFAULT 0, | |
| 9 | + | added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 10 | + | PRIMARY KEY (bundle_id, item_id), | |
| 11 | + | CHECK (bundle_id != item_id) | |
| 12 | + | ); | |
| 13 | + | ||
| 14 | + | CREATE INDEX idx_bundle_items_item ON bundle_items(item_id); |
| @@ -0,0 +1,189 @@ | |||
| 1 | + | //! Bundle item management: linking items into bundles and checking bundle-based access. | |
| 2 | + | ||
| 3 | + | use sqlx::PgPool; | |
| 4 | + | ||
| 5 | + | use super::models::DbItem; | |
| 6 | + | use super::{ItemId, ProjectId, UserId}; | |
| 7 | + | use crate::error::Result; | |
| 8 | + | ||
| 9 | + | /// Add an item to a bundle at the given sort position. | |
| 10 | + | /// | |
| 11 | + | /// Uses `ON CONFLICT DO NOTHING` so re-adding is idempotent. | |
| 12 | + | pub async fn add_item_to_bundle( | |
| 13 | + | pool: &PgPool, | |
| 14 | + | bundle_id: ItemId, | |
| 15 | + | item_id: ItemId, | |
| 16 | + | sort_order: i32, | |
| 17 | + | ) -> Result<()> { | |
| 18 | + | sqlx::query( | |
| 19 | + | r#" | |
| 20 | + | INSERT INTO bundle_items (bundle_id, item_id, sort_order) | |
| 21 | + | VALUES ($1, $2, $3) | |
| 22 | + | ON CONFLICT (bundle_id, item_id) DO UPDATE SET sort_order = $3 | |
| 23 | + | "#, | |
| 24 | + | ) | |
| 25 | + | .bind(bundle_id) | |
| 26 | + | .bind(item_id) | |
| 27 | + | .bind(sort_order) | |
| 28 | + | .execute(pool) | |
| 29 | + | .await?; | |
| 30 | + | ||
| 31 | + | Ok(()) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// Remove an item from a bundle. | |
| 35 | + | pub async fn remove_item_from_bundle( | |
| 36 | + | pool: &PgPool, | |
| 37 | + | bundle_id: ItemId, | |
| 38 | + | item_id: ItemId, | |
| 39 | + | ) -> Result<()> { | |
| 40 | + | sqlx::query("DELETE FROM bundle_items WHERE bundle_id = $1 AND item_id = $2") | |
| 41 | + | .bind(bundle_id) | |
| 42 | + | .bind(item_id) | |
| 43 | + | .execute(pool) | |
| 44 | + | .await?; | |
| 45 | + | ||
| 46 | + | Ok(()) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Get all items included in a bundle, ordered by sort_order. | |
| 50 | + | pub async fn get_bundle_items(pool: &PgPool, bundle_id: ItemId) -> Result<Vec<DbItem>> { | |
| 51 | + | let items = sqlx::query_as::<_, DbItem>( | |
| 52 | + | r#" | |
| 53 | + | SELECT i.* FROM items i | |
| 54 | + | JOIN bundle_items bi ON i.id = bi.item_id | |
| 55 | + | WHERE bi.bundle_id = $1 | |
| 56 | + | ORDER BY bi.sort_order, bi.added_at | |
| 57 | + | LIMIT 100 | |
| 58 | + | "#, | |
| 59 | + | ) | |
| 60 | + | .bind(bundle_id) | |
| 61 | + | .fetch_all(pool) | |
| 62 | + | .await?; | |
| 63 | + | ||
| 64 | + | Ok(items) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// Get the IDs of all bundles that contain a given item. | |
| 68 | + | pub async fn get_bundles_containing_item( | |
| 69 | + | pool: &PgPool, | |
| 70 | + | item_id: ItemId, | |
| 71 | + | ) -> Result<Vec<ItemId>> { | |
| 72 | + | let ids: Vec<ItemId> = sqlx::query_scalar( | |
| 73 | + | "SELECT bundle_id FROM bundle_items WHERE item_id = $1", | |
| 74 | + | ) | |
| 75 | + | .bind(item_id) | |
| 76 | + | .fetch_all(pool) | |
| 77 | + | .await?; | |
| 78 | + | ||
| 79 | + | Ok(ids) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// Check whether a user has access to an item through any purchased bundle. | |
| 83 | + | /// | |
| 84 | + | /// Returns true if the user has a completed transaction for any bundle | |
| 85 | + | /// that contains this item. | |
| 86 | + | pub async fn has_access_via_bundle( | |
| 87 | + | pool: &PgPool, | |
| 88 | + | user_id: UserId, | |
| 89 | + | item_id: ItemId, | |
| 90 | + | ) -> Result<bool> { | |
| 91 | + | let exists: bool = sqlx::query_scalar( | |
| 92 | + | r#" | |
| 93 | + | SELECT EXISTS( | |
| 94 | + | SELECT 1 FROM bundle_items bi | |
| 95 | + | JOIN transactions t ON t.item_id = bi.bundle_id | |
| 96 | + | WHERE bi.item_id = $1 | |
| 97 | + | AND t.buyer_id = $2 | |
| 98 | + | AND t.status = 'completed' | |
| 99 | + | ) | |
| 100 | + | "#, | |
| 101 | + | ) | |
| 102 | + | .bind(item_id) | |
| 103 | + | .bind(user_id) | |
| 104 | + | .fetch_one(pool) | |
| 105 | + | .await?; | |
| 106 | + | ||
| 107 | + | Ok(exists) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// Get all non-bundle items in a project (candidates for inclusion in a bundle). | |
| 111 | + | /// | |
| 112 | + | /// Excludes the bundle itself (by `exclude_bundle_id`) and any items that are | |
| 113 | + | /// already bundles (to prevent nesting). | |
| 114 | + | pub async fn get_bundleable_items( | |
| 115 | + | pool: &PgPool, | |
| 116 | + | project_id: ProjectId, | |
| 117 | + | exclude_bundle_id: Option<ItemId>, | |
| 118 | + | ) -> Result<Vec<DbItem>> { | |
| 119 | + | let items = sqlx::query_as::<_, DbItem>( | |
| 120 | + | r#" | |
| 121 | + | SELECT * FROM items | |
| 122 | + | WHERE project_id = $1 | |
| 123 | + | AND item_type != 'bundle' | |
| 124 | + | AND ($2::UUID IS NULL OR id != $2) | |
| 125 | + | ORDER BY sort_order, created_at DESC | |
| 126 | + | LIMIT 500 | |
| 127 | + | "#, | |
| 128 | + | ) | |
| 129 | + | .bind(project_id) | |
| 130 | + | .bind(exclude_bundle_id) | |
| 131 | + | .fetch_all(pool) | |
| 132 | + | .await?; | |
| 133 | + | ||
| 134 | + | Ok(items) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// Replace the full set of items in a bundle (transactional). | |
| 138 | + | /// | |
| 139 | + | /// Deletes all existing bundle_items rows for the bundle and inserts the new set. | |
| 140 | + | /// `item_ids` is an ordered list — sort_order is derived from position. | |
| 141 | + | pub async fn set_bundle_items( | |
| 142 | + | pool: &PgPool, | |
| 143 | + | bundle_id: ItemId, | |
| 144 | + | item_ids: &[ItemId], | |
| 145 | + | ) -> Result<()> { | |
| 146 | + | let mut tx = pool.begin().await?; | |
| 147 | + | ||
| 148 | + | sqlx::query("DELETE FROM bundle_items WHERE bundle_id = $1") | |
| 149 | + | .bind(bundle_id) | |
| 150 | + | .execute(&mut *tx) | |
| 151 | + | .await?; | |
| 152 | + | ||
| 153 | + | for (i, item_id) in item_ids.iter().enumerate() { | |
| 154 | + | sqlx::query( | |
| 155 | + | "INSERT INTO bundle_items (bundle_id, item_id, sort_order) VALUES ($1, $2, $3)", | |
| 156 | + | ) | |
| 157 | + | .bind(bundle_id) | |
| 158 | + | .bind(item_id) | |
| 159 | + | .bind(i as i32) | |
| 160 | + | .execute(&mut *tx) | |
| 161 | + | .await?; | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | tx.commit().await?; | |
| 165 | + | Ok(()) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | /// Count how many items are in a bundle. | |
| 169 | + | pub async fn get_bundle_item_count(pool: &PgPool, bundle_id: ItemId) -> Result<i64> { | |
| 170 | + | let count: i64 = sqlx::query_scalar( | |
| 171 | + | "SELECT COUNT(*) FROM bundle_items WHERE bundle_id = $1", | |
| 172 | + | ) | |
| 173 | + | .bind(bundle_id) | |
| 174 | + | .fetch_one(pool) | |
| 175 | + | .await?; | |
| 176 | + | ||
| 177 | + | Ok(count) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | /// Set the `listed` flag on an item. | |
| 181 | + | pub async fn set_item_listed(pool: &PgPool, item_id: ItemId, listed: bool) -> Result<()> { | |
| 182 | + | sqlx::query("UPDATE items SET listed = $2 WHERE id = $1") | |
| 183 | + | .bind(item_id) | |
| 184 | + | .bind(listed) | |
| 185 | + | .execute(pool) | |
| 186 | + | .await?; | |
| 187 | + | ||
| 188 | + | Ok(()) | |
| 189 | + | } |
| @@ -300,6 +300,7 @@ pub enum ItemType { | |||
| 300 | 300 | Course, | |
| 301 | 301 | Template, | |
| 302 | 302 | Digital, | |
| 303 | + | Bundle, | |
| 303 | 304 | } | |
| 304 | 305 | ||
| 305 | 306 | impl_str_enum!(ItemType { | |
| @@ -313,6 +314,7 @@ impl_str_enum!(ItemType { | |||
| 313 | 314 | Course => "course", | |
| 314 | 315 | Template => "template", | |
| 315 | 316 | Digital => "digital", | |
| 317 | + | Bundle => "bundle", | |
| 316 | 318 | }); | |
| 317 | 319 | ||
| 318 | 320 | impl ItemType { | |
| @@ -329,6 +331,7 @@ impl ItemType { | |||
| 329 | 331 | Self::Course => "Course", | |
| 330 | 332 | Self::Template => "Template", | |
| 331 | 333 | Self::Digital => "Digital", | |
| 334 | + | Self::Bundle => "Bundle", | |
| 332 | 335 | } | |
| 333 | 336 | } | |
| 334 | 337 | ||
| @@ -337,11 +340,13 @@ impl ItemType { | |||
| 337 | 340 | /// Determines what the content step looks like: | |
| 338 | 341 | /// - `"text"` → Markdown editor | |
| 339 | 342 | /// - `"audio"` → Audio file upload | |
| 343 | + | /// - `"bundle"` → Item picker for bundle contents | |
| 340 | 344 | /// - `"file"` → Generic file upload | |
| 341 | 345 | pub fn wizard_group(&self) -> &'static str { | |
| 342 | 346 | match self { | |
| 343 | 347 | Self::Text => "text", | |
| 344 | 348 | Self::Audio => "audio", | |
| 349 | + | Self::Bundle => "bundle", | |
| 345 | 350 | _ => "file", | |
| 346 | 351 | } | |
| 347 | 352 | } | |
| @@ -590,7 +595,7 @@ impl ProjectFeature { | |||
| 590 | 595 | .filter(|(value, _, _)| { | |
| 591 | 596 | value | |
| 592 | 597 | .parse::<ItemType>() | |
| 593 | - | .map(|t| allowed.contains(&t)) | |
| 598 | + | .map(|t| t == ItemType::Bundle || allowed.contains(&t)) | |
| 594 | 599 | .unwrap_or(false) | |
| 595 | 600 | }) | |
| 596 | 601 | .copied() | |
| @@ -610,6 +615,7 @@ impl ProjectFeature { | |||
| 610 | 615 | ("preset", "Preset Pack", "Synth presets, effect chains"), | |
| 611 | 616 | ("template", "Template", "Design templates, starter kits"), | |
| 612 | 617 | ("image", "Image", "Photos, artwork, graphics"), | |
| 618 | + | ("bundle", "Bundle", "Collection of other items"), | |
| 613 | 619 | ] | |
| 614 | 620 | } | |
| 615 | 621 | ||
| @@ -636,6 +642,7 @@ impl ProjectFeature { | |||
| 636 | 642 | let (label, desc) = match group { | |
| 637 | 643 | "text" => ("Text", "Write in the editor"), | |
| 638 | 644 | "audio" => ("Audio", "Upload audio files"), | |
| 645 | + | "bundle" => ("Bundle", "Collection of other items"), | |
| 639 | 646 | _ => ("File", "Upload any file"), | |
| 640 | 647 | }; | |
| 641 | 648 | cards.push((*value, label, desc)); | |
| @@ -821,6 +828,8 @@ mod tests { | |||
| 821 | 828 | fn item_type_round_trip() { | |
| 822 | 829 | assert_eq!(ItemType::Audio.to_string(), "audio"); | |
| 823 | 830 | assert_eq!("plugin".parse::<ItemType>().unwrap(), ItemType::Plugin); | |
| 831 | + | assert_eq!(ItemType::Bundle.to_string(), "bundle"); | |
| 832 | + | assert_eq!("bundle".parse::<ItemType>().unwrap(), ItemType::Bundle); | |
| 824 | 833 | } | |
| 825 | 834 | ||
| 826 | 835 | #[test] | |
| @@ -1008,6 +1017,7 @@ mod tests { | |||
| 1008 | 1017 | assert!(values.contains(&"audio")); | |
| 1009 | 1018 | assert!(values.contains(&"sample")); | |
| 1010 | 1019 | assert!(values.contains(&"preset")); | |
| 1020 | + | assert!(values.contains(&"bundle")); // Bundle always included | |
| 1011 | 1021 | assert!(!values.contains(&"text")); | |
| 1012 | 1022 | assert!(!values.contains(&"digital")); | |
| 1013 | 1023 | } | |
| @@ -1018,20 +1028,21 @@ mod tests { | |||
| 1018 | 1028 | let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect(); | |
| 1019 | 1029 | assert!(values.contains(&"audio")); | |
| 1020 | 1030 | assert!(values.contains(&"text")); | |
| 1031 | + | assert!(values.contains(&"bundle")); // Bundle always included | |
| 1021 | 1032 | assert!(!values.contains(&"digital")); | |
| 1022 | 1033 | } | |
| 1023 | 1034 | ||
| 1024 | 1035 | #[test] | |
| 1025 | 1036 | fn project_feature_allowed_cards_empty_features_shows_all() { | |
| 1026 | 1037 | let cards = ProjectFeature::allowed_item_type_cards(&[]); | |
| 1027 | - | assert_eq!(cards.len(), ProjectFeature::all_item_type_cards().len()); | |
| 1038 | + | assert_eq!(cards.len(), 11); // 10 content types + bundle | |
| 1028 | 1039 | } | |
| 1029 | 1040 | ||
| 1030 | 1041 | #[test] | |
| 1031 | 1042 | fn project_feature_allowed_cards_non_content_features_shows_all() { | |
| 1032 | 1043 | let cards = ProjectFeature::allowed_item_type_cards(&["blog".into(), "subscriptions".into()]); | |
| 1033 | 1044 | // Blog and subscriptions don't gate item types, so all should be shown | |
| 1034 | - | assert_eq!(cards.len(), ProjectFeature::all_item_type_cards().len()); | |
| 1045 | + | assert_eq!(cards.len(), 11); // 10 content types + bundle | |
| 1035 | 1046 | } | |
| 1036 | 1047 | ||
| 1037 | 1048 | #[test] | |
| @@ -1152,45 +1163,55 @@ mod tests { | |||
| 1152 | 1163 | } | |
| 1153 | 1164 | } | |
| 1154 | 1165 | ||
| 1166 | + | #[test] | |
| 1167 | + | fn wizard_group_bundle() { | |
| 1168 | + | assert_eq!(ItemType::Bundle.wizard_group(), "bundle"); | |
| 1169 | + | } | |
| 1170 | + | ||
| 1155 | 1171 | // ── ProjectFeature::wizard_type_cards ── | |
| 1156 | 1172 | ||
| 1157 | 1173 | #[test] | |
| 1158 | - | fn wizard_cards_text_only_one_group() { | |
| 1174 | + | fn wizard_cards_text_only_two_groups() { | |
| 1175 | + | // Text + bundle (bundle always included) | |
| 1159 | 1176 | let cards = ProjectFeature::wizard_type_cards(&["text".into()]); | |
| 1160 | - | assert_eq!(cards.len(), 1); | |
| 1161 | - | assert_eq!(cards[0].0, "text"); | |
| 1177 | + | assert_eq!(cards.len(), 2); | |
| 1178 | + | let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect(); | |
| 1179 | + | assert!(groups.contains(&"text")); | |
| 1180 | + | assert!(groups.contains(&"bundle")); | |
| 1162 | 1181 | } | |
| 1163 | 1182 | ||
| 1164 | 1183 | #[test] | |
| 1165 | - | fn wizard_cards_downloads_only_one_group() { | |
| 1166 | - | // All download types are in the "file" group → single card | |
| 1184 | + | fn wizard_cards_downloads_two_groups() { | |
| 1185 | + | // All download types are in the "file" group + bundle → 2 cards | |
| 1167 | 1186 | let cards = ProjectFeature::wizard_type_cards(&["downloads".into()]); | |
| 1168 | - | assert_eq!(cards.len(), 1); | |
| 1169 | - | assert_eq!(cards[0].0, "digital"); // first type in file group | |
| 1187 | + | assert_eq!(cards.len(), 2); | |
| 1188 | + | let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect(); | |
| 1189 | + | assert!(groups.contains(&"digital")); // first type in file group | |
| 1190 | + | assert!(groups.contains(&"bundle")); | |
| 1170 | 1191 | } | |
| 1171 | 1192 | ||
| 1172 | 1193 | #[test] | |
| 1173 | - | fn wizard_cards_audio_feature_two_groups() { | |
| 1174 | - | // Audio feature allows audio (audio group) + sample, preset (file group) | |
| 1194 | + | fn wizard_cards_audio_feature_three_groups() { | |
| 1195 | + | // Audio feature allows audio (audio group) + sample, preset (file group) + bundle | |
| 1175 | 1196 | let cards = ProjectFeature::wizard_type_cards(&["audio".into()]); | |
| 1176 | - | assert_eq!(cards.len(), 2); | |
| 1197 | + | assert_eq!(cards.len(), 3); | |
| 1177 | 1198 | let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect(); | |
| 1178 | 1199 | assert!(groups.contains(&"audio")); | |
| 1179 | - | // sample is the first file-group type among audio feature's allowed types | |
| 1180 | - | assert!(groups.contains(&"sample")); | |
| 1200 | + | assert!(groups.contains(&"sample")); // first file-group type | |
| 1201 | + | assert!(groups.contains(&"bundle")); | |
| 1181 | 1202 | } | |
| 1182 | 1203 | ||
| 1183 | 1204 | #[test] | |
| 1184 | - | fn wizard_cards_text_and_audio_three_groups() { | |
| 1205 | + | fn wizard_cards_text_and_audio_four_groups() { | |
| 1185 | 1206 | let cards = | |
| 1186 | 1207 | ProjectFeature::wizard_type_cards(&["text".into(), "audio".into()]); | |
| 1187 | - | assert_eq!(cards.len(), 3); | |
| 1208 | + | assert_eq!(cards.len(), 4); // text, audio, file, bundle | |
| 1188 | 1209 | } | |
| 1189 | 1210 | ||
| 1190 | 1211 | #[test] | |
| 1191 | - | fn wizard_cards_empty_features_all_three_groups() { | |
| 1192 | - | // No content features → all types → 3 wizard groups | |
| 1212 | + | fn wizard_cards_empty_features_all_four_groups() { | |
| 1213 | + | // No content features → all types → 4 wizard groups (text, audio, file, bundle) | |
| 1193 | 1214 | let cards = ProjectFeature::wizard_type_cards(&[]); | |
| 1194 | - | assert_eq!(cards.len(), 3); | |
| 1215 | + | assert_eq!(cards.len(), 4); | |
| 1195 | 1216 | } | |
| 1196 | 1217 | } |
| @@ -137,7 +137,7 @@ pub async fn get_items_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbI | |||
| 137 | 137 | /// Capped at 500 as a safety limit. | |
| 138 | 138 | pub async fn get_public_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> { | |
| 139 | 139 | let items = sqlx::query_as::<_, DbItem>( | |
| 140 | - | "SELECT * FROM items WHERE project_id = $1 AND is_public = true ORDER BY sort_order, created_at DESC LIMIT 500", | |
| 140 | + | "SELECT * FROM items WHERE project_id = $1 AND is_public = true AND listed = true ORDER BY sort_order, created_at DESC LIMIT 500", | |
| 141 | 141 | ) | |
| 142 | 142 | .bind(project_id) | |
| 143 | 143 | .fetch_all(pool) | |
| @@ -284,7 +284,7 @@ pub async fn get_public_items_by_user(pool: &PgPool, user_id: UserId) -> Result< | |||
| 284 | 284 | r#" | |
| 285 | 285 | SELECT i.* FROM items i | |
| 286 | 286 | JOIN projects p ON i.project_id = p.id | |
| 287 | - | WHERE p.user_id = $1 AND p.is_public = true AND i.is_public = true | |
| 287 | + | WHERE p.user_id = $1 AND p.is_public = true AND i.is_public = true AND i.listed = true | |
| 288 | 288 | ORDER BY i.created_at DESC | |
| 289 | 289 | LIMIT 50 | |
| 290 | 290 | "#, |
| @@ -50,6 +50,7 @@ pub(crate) mod creator_tiers; | |||
| 50 | 50 | pub(crate) mod mailing_lists; | |
| 51 | 51 | pub mod custom_domains; | |
| 52 | 52 | pub mod patches; | |
| 53 | + | pub mod bundles; | |
| 53 | 54 | ||
| 54 | 55 | pub use id_types::*; | |
| 55 | 56 | pub use validated_types::*; |
| @@ -308,6 +308,8 @@ pub struct DbItem { | |||
| 308 | 308 | pub cover_file_size_bytes: Option<i64>, | |
| 309 | 309 | /// URL-safe slug unique per project (for custom domain pretty URLs). | |
| 310 | 310 | pub slug: String, | |
| 311 | + | /// Whether this item appears on the project page (unlisted items are bundle-only). | |
| 312 | + | pub listed: bool, | |
| 311 | 313 | } | |
| 312 | 314 | ||
| 313 | 315 | /// Content-type-specific data extracted from a `DbItem`. | |
| @@ -1991,6 +1993,7 @@ mod tests { | |||
| 1991 | 1993 | audio_file_size_bytes: None, | |
| 1992 | 1994 | cover_file_size_bytes: None, | |
| 1993 | 1995 | slug: "test".to_string(), | |
| 1996 | + | listed: true, | |
| 1994 | 1997 | } | |
| 1995 | 1998 | } | |
| 1996 | 1999 |
| @@ -664,6 +664,7 @@ mod tests { | |||
| 664 | 664 | audio_file_size_bytes: None, | |
| 665 | 665 | cover_file_size_bytes: None, | |
| 666 | 666 | slug: "test".to_string(), | |
| 667 | + | listed: true, | |
| 667 | 668 | } | |
| 668 | 669 | } | |
| 669 | 670 |
| @@ -222,9 +222,9 @@ pub(super) async fn delete_item( | |||
| 222 | 222 | State(state): State<AppState>, | |
| 223 | 223 | AuthUser(user): AuthUser, | |
| 224 | 224 | Path(id): Path<ItemId>, | |
| 225 | - | ) -> Result<impl IntoResponse> { | |
| 225 | + | ) -> Result<Response> { | |
| 226 | 226 | user.check_not_suspended()?; | |
| 227 | - | let (item, _) = verify_item_ownership(&state, id, user.id).await?; | |
| 227 | + | let (item, project) = verify_item_ownership(&state, id, user.id).await?; | |
| 228 | 228 | ||
| 229 | 229 | // Calculate total file bytes to decrement from storage counter | |
| 230 | 230 | let file_sizes = db::items::get_item_file_sizes(&state.db, id).await?; | |
| @@ -241,7 +241,14 @@ pub(super) async fn delete_item( | |||
| 241 | 241 | db::creator_tiers::decrement_storage_used(&state.db, user.id, total_bytes).await?; | |
| 242 | 242 | } | |
| 243 | 243 | ||
| 244 | - | Ok(htmx_toast_response("Item deleted", "success")) | |
| 244 | + | let mut response = Response::new(axum::body::Body::empty()); | |
| 245 | + | response.headers_mut().insert( | |
| 246 | + | "HX-Redirect", | |
| 247 | + | format!("/dashboard/project/{}", project.slug) | |
| 248 | + | .parse() | |
| 249 | + | .expect("redirect path is valid"), | |
| 250 | + | ); | |
| 251 | + | Ok(response) | |
| 245 | 252 | } | |
| 246 | 253 | ||
| 247 | 254 | /// Duplicate an item and its metadata, creating a new draft. |
| @@ -285,6 +285,20 @@ pub(super) async fn dashboard_item( | |||
| 285 | 285 | let license_keys: Vec<LicenseKeyRow> = db_license_keys.into_iter().map(LicenseKeyRow::from).collect(); | |
| 286 | 286 | let promo_codes: Vec<PromoCodeRow> = db_promo_codes.into_iter().map(PromoCodeRow::from).collect(); | |
| 287 | 287 | ||
| 288 | + | // Load bundle child items if this is a bundle | |
| 289 | + | let bundle_items = if db_item.item_type == db::ItemType::Bundle { | |
| 290 | + | let children = db::bundles::get_bundle_items(&state.db, item_id).await?; | |
| 291 | + | children | |
| 292 | + | .iter() | |
| 293 | + | .map(|child| { | |
| 294 | + | let child_tags = vec![]; // Tags not needed for dashboard bundle list | |
| 295 | + | Item::from_db_list(child, &child_tags, false, true) | |
| 296 | + | }) | |
| 297 | + | .collect() | |
| 298 | + | } else { | |
| 299 | + | vec![] | |
| 300 | + | }; | |
| 301 | + | ||
| 288 | 302 | Ok(DashboardItemTemplate { | |
| 289 | 303 | csrf_token, | |
| 290 | 304 | session_user: Some(session_user), | |
| @@ -294,6 +308,7 @@ pub(super) async fn dashboard_item( | |||
| 294 | 308 | license_keys, | |
| 295 | 309 | promo_codes, | |
| 296 | 310 | project_labels, | |
| 311 | + | bundle_items, | |
| 297 | 312 | }) | |
| 298 | 313 | } | |
| 299 | 314 |
| @@ -310,11 +310,46 @@ async fn save_content( | |||
| 310 | 310 | item: &db::DbItem, | |
| 311 | 311 | form: &HashMap<String, String>, | |
| 312 | 312 | ) -> Result<()> { | |
| 313 | - | // Text items: save body directly | |
| 314 | - | if item.item_type == ItemType::Text | |
| 315 | - | && let Some(body) = form.get("body") | |
| 316 | - | { | |
| 317 | - | db::items::update_item_text(&state.db, item.id, body).await?; | |
| 313 | + | if item.item_type == ItemType::Text { | |
| 314 | + | // Text items: save body directly | |
| 315 | + | if let Some(body) = form.get("body") { | |
| 316 | + | db::items::update_item_text(&state.db, item.id, body).await?; | |
| 317 | + | } | |
| 318 | + | } else if item.item_type == ItemType::Bundle { | |
| 319 | + | // Bundle items: parse selected item IDs and unlisted flags | |
| 320 | + | let bundle_ids: Vec<ItemId> = form | |
| 321 | + | .get("bundle_item_ids") | |
| 322 | + | .map(|s| { | |
| 323 | + | s.split(',') | |
| 324 | + | .filter(|v| !v.is_empty()) | |
| 325 | + | .filter_map(|v| v.parse().ok()) | |
| 326 | + | .collect() | |
| 327 | + | }) | |
| 328 | + | .unwrap_or_default(); | |
| 329 | + | ||
| 330 | + | let unlisted_ids: Vec<ItemId> = form | |
| 331 | + | .get("unlisted_item_ids") | |
| 332 | + | .map(|s| { | |
| 333 | + | s.split(',') | |
| 334 | + | .filter(|v| !v.is_empty()) | |
| 335 | + | .filter_map(|v| v.parse().ok()) | |
| 336 | + | .collect() | |
| 337 | + | }) | |
| 338 | + | .unwrap_or_default(); | |
| 339 | + | ||
| 340 | + | // Set bundle contents (replaces all existing) | |
| 341 | + | db::bundles::set_bundle_items(&state.db, item.id, &bundle_ids).await?; | |
| 342 | + | ||
| 343 | + | // Update listed status for all bundleable items in this project | |
| 344 | + | let all_bundleable = | |
| 345 | + | db::bundles::get_bundleable_items(&state.db, item.project_id, Some(item.id)).await?; | |
| 346 | + | for bi in &all_bundleable { | |
| 347 | + | let should_be_unlisted = unlisted_ids.contains(&bi.id); | |
| 348 | + | if bi.listed == should_be_unlisted { | |
| 349 | + | // listed=true but should be unlisted, or listed=false but shouldn't be | |
| 350 | + | db::bundles::set_item_listed(&state.db, bi.id, !should_be_unlisted).await?; | |
| 351 | + | } | |
| 352 | + | } | |
| 318 | 353 | } | |
| 319 | 354 | // Audio/file items: content uploaded via presign flow (client-side S3) | |
| 320 | 355 | Ok(()) | |
| @@ -544,12 +579,45 @@ async fn render_step( | |||
| 544 | 579 | ||
| 545 | 580 | "content" => { | |
| 546 | 581 | let content_template = item.item_type.to_string(); | |
| 582 | + | ||
| 583 | + | // Load bundle data if this is a bundle item | |
| 584 | + | let (bundleable_items, selected_bundle_ids, unlisted_ids) = | |
| 585 | + | if item.item_type == ItemType::Bundle { | |
| 586 | + | let bundleable = | |
| 587 | + | db::bundles::get_bundleable_items(&state.db, project.id, Some(item.id)) | |
| 588 | + | .await?; | |
| 589 | + | let current = db::bundles::get_bundle_items(&state.db, item.id).await?; | |
| 590 | + | let selected: Vec<String> = | |
| 591 | + | current.iter().map(|i| i.id.to_string()).collect(); | |
| 592 | + | let unlisted: Vec<String> = current | |
| 593 | + | .iter() | |
| 594 | + | .chain(bundleable.iter()) | |
| 595 | + | .filter(|i| !i.listed) | |
| 596 | + | .map(|i| i.id.to_string()) | |
| 597 | + | .collect(); | |
| 598 | + | let bi: Vec<BundleableItem> = bundleable | |
| 599 | + | .iter() | |
| 600 | + | .map(|i| BundleableItem { | |
| 601 | + | id: i.id.to_string(), | |
| 602 | + | title: i.title.clone(), | |
| 603 | + | item_type: i.item_type.label().to_string(), | |
| 604 | + | }) | |
| 605 | + | .collect(); | |
| 606 | + | (bi, selected, unlisted) | |
| 607 | + | } else { | |
| 608 | + | (vec![], vec![], vec![]) | |
| 609 | + | }; | |
| 610 | + | ||
| 547 | 611 | Ok(WizardItemContentTemplate { | |
| 548 | 612 | nav, | |
| 549 | 613 | project_slug, | |
| 614 | + | project_id: project.id.to_string(), | |
| 550 | 615 | item_id, | |
| 551 | 616 | item_type: content_template, | |
| 552 | 617 | body: item.body.clone().unwrap_or_default(), | |
| 618 | + | bundleable_items, | |
| 619 | + | selected_bundle_ids, | |
| 620 | + | unlisted_ids, | |
| 553 | 621 | } | |
| 554 | 622 | .into_response()) | |
| 555 | 623 | } | |
| @@ -638,7 +706,12 @@ async fn render_step( | |||
| 638 | 706 | tag_names, | |
| 639 | 707 | enable_license_keys: item.enable_license_keys, | |
| 640 | 708 | has_content: item.body.is_some() | |
| 641 | - | || item.audio_s3_key.is_some(), | |
| 709 | + | || item.audio_s3_key.is_some() | |
| 710 | + | || (item.item_type == ItemType::Bundle | |
| 711 | + | && db::bundles::get_bundle_item_count(&state.db, item.id) | |
| 712 | + | .await | |
| 713 | + | .unwrap_or(0) | |
| 714 | + | > 0), | |
| 642 | 715 | is_public: item.is_public, | |
| 643 | 716 | } | |
| 644 | 717 | .into_response()) |
| @@ -175,7 +175,11 @@ pub(crate) async fn render_project_page( | |||
| 175 | 175 | let can_access = item_pricing.can_access(&ctx); | |
| 176 | 176 | let is_free = item_pricing.is_free(); | |
| 177 | 177 | let item_tags = tags_map.get(&i.id).map(|v| v.as_slice()).unwrap_or(&[]); | |
| 178 | - | items.push(Item::from_db_list(i, item_tags, is_free, can_access)); | |
| 178 | + | let mut item = Item::from_db_list(i, item_tags, is_free, can_access); | |
| 179 | + | if i.item_type == ItemType::Bundle { | |
| 180 | + | item.bundle_item_count = db::bundles::get_bundle_item_count(&state.db, i.id).await?; | |
| 181 | + | } | |
| 182 | + | items.push(item); | |
| 179 | 183 | } | |
| 180 | 184 | ||
| 181 | 185 | let follower_count = db::follows::get_follower_count(&state.db, FollowTargetType::Project, db_project.id.into()).await?; | |
| @@ -292,9 +296,43 @@ pub(crate) async fn render_item_page( | |||
| 292 | 296 | has_purchased: in_library, | |
| 293 | 297 | has_active_subscription: has_item_sub, | |
| 294 | 298 | }; | |
| 295 | - | let has_access = item_pricing.can_access(&ctx); | |
| 299 | + | let mut has_access = item_pricing.can_access(&ctx); | |
| 296 | 300 | let is_free = item_pricing.is_free(); | |
| 297 | 301 | ||
| 302 | + | // Bundle access: user may have purchased a bundle containing this item | |
| 303 | + | if !has_access { | |
| 304 | + | if let Some(ref user) = maybe_user { | |
| 305 | + | if db::bundles::has_access_via_bundle(&state.db, user.id, db_item.id).await? { | |
| 306 | + | has_access = true; | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | // For unlisted items, load the bundles that contain them (for "Available in" display) | |
| 312 | + | let containing_bundle_ids = if !db_item.listed { | |
| 313 | + | db::bundles::get_bundles_containing_item(&state.db, db_item.id).await? | |
| 314 | + | } else { | |
| 315 | + | vec![] | |
| 316 | + | }; | |
| 317 | + | let containing_bundles: Vec<db::DbItem> = { | |
| 318 | + | let mut bundles = Vec::new(); | |
| 319 | + | for bid in &containing_bundle_ids { | |
| 320 | + | if let Some(b) = db::items::get_item_by_id(&state.db, *bid).await? { | |
| 321 | + | if b.is_public { | |
| 322 | + | bundles.push(b); | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | bundles | |
| 327 | + | }; | |
| 328 | + | ||
| 329 | + | // For bundle-type items, load the child items | |
| 330 | + | let bundle_child_items = if db_item.item_type == ItemType::Bundle { | |
| 331 | + | db::bundles::get_bundle_items(&state.db, db_item.id).await? | |
| 332 | + | } else { | |
| 333 | + | vec![] | |
| 334 | + | }; | |
| 335 | + | ||
| 298 | 336 | let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?; | |
| 299 | 337 | let item = Item::from_db_detail(db_item, &item_tags, body_html.clone(), reading_time.clone(), is_free, has_access); | |
| 300 | 338 | ||
| @@ -387,6 +425,18 @@ pub(crate) async fn render_item_page( | |||
| 387 | 425 | let (discussion_url, discussion_count) = | |
| 388 | 426 | fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await; | |
| 389 | 427 | ||
| 428 | + | // Convert bundle child items to view models | |
| 429 | + | let bundle_item_views: Vec<Item> = bundle_child_items.iter().map(|child| { | |
| 430 | + | let child_tags = Vec::new(); // Tags not needed for bundle child list display | |
| 431 | + | Item::from_db_list(child, &child_tags, child.price_cents == 0, false) | |
| 432 | + | }).collect(); | |
| 433 | + | ||
| 434 | + | // Convert containing bundles to view models | |
| 435 | + | let containing_bundle_views: Vec<Item> = containing_bundles.iter().map(|b| { | |
| 436 | + | let b_tags = Vec::new(); | |
| 437 | + | Item::from_db_list(b, &b_tags, b.price_cents == 0, false) | |
| 438 | + | }).collect(); | |
| 439 | + | ||
| 390 | 440 | Ok(ItemTemplate { | |
| 391 | 441 | csrf_token, | |
| 392 | 442 | session_user: maybe_user, | |
| @@ -398,6 +448,8 @@ pub(crate) async fn render_item_page( | |||
| 398 | 448 | host_url: state.config.host_url.clone(), | |
| 399 | 449 | discussion_url, | |
| 400 | 450 | discussion_count, | |
| 451 | + | bundle_items: bundle_item_views, | |
| 452 | + | containing_bundles: containing_bundle_views, | |
| 401 | 453 | }.into_response()) | |
| 402 | 454 | } | |
| 403 | 455 |
| @@ -397,7 +397,10 @@ async fn stream_url( | |||
| 397 | 397 | has_active_subscription: db::subscriptions::has_active_subscription_to_item(&state.db, user.id, item_id).await?, | |
| 398 | 398 | }; | |
| 399 | 399 | if !item_pricing.can_access(&ctx) { | |
| 400 | - | return Err(AppError::Forbidden); | |
| 400 | + | // Check bundle access as fallback | |
| 401 | + | if !db::bundles::has_access_via_bundle(&state.db, user.id, item_id).await? { | |
| 402 | + | return Err(AppError::Forbidden); | |
| 403 | + | } | |
| 401 | 404 | } | |
| 402 | 405 | } | |
| 403 | 406 | ||
| @@ -602,7 +605,10 @@ async fn version_download( | |||
| 602 | 605 | has_active_subscription: db::subscriptions::has_active_subscription_to_item(&state.db, user.id, version.item_id).await?, | |
| 603 | 606 | }; | |
| 604 | 607 | if !item_pricing.can_access(&ctx) { | |
| 605 | - | return Err(AppError::Forbidden); | |
| 608 | + | // Check bundle access as fallback | |
| 609 | + | if !db::bundles::has_access_via_bundle(&state.db, user.id, version.item_id).await? { | |
| 610 | + | return Err(AppError::Forbidden); | |
| 611 | + | } | |
| 606 | 612 | } | |
| 607 | 613 | } | |
| 608 | 614 |
| @@ -49,6 +49,11 @@ pub(super) async fn create_checkout( | |||
| 49 | 49 | return Err(AppError::BadRequest("This item is not available for purchase".to_string())); | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | + | // Unlisted items can only be obtained through their bundle | |
| 53 | + | if !item.listed { | |
| 54 | + | return Err(AppError::BadRequest("This item is only available as part of a bundle".to_string())); | |
| 55 | + | } | |
| 56 | + | ||
| 52 | 57 | // Free items don't need checkout | |
| 53 | 58 | let item_pricing = pricing::for_item(&item); | |
| 54 | 59 | if item_pricing.checkout_type() == CheckoutType::None { | |
| @@ -216,6 +221,10 @@ pub(super) async fn create_checkout( | |||
| 216 | 221 | }; | |
| 217 | 222 | ||
| 218 | 223 | if claimed { | |
| 224 | + | // Grant access to bundle child items (if this is a bundle) | |
| 225 | + | if item.item_type == db::ItemType::Bundle { | |
| 226 | + | grant_bundle_items(&state, item_uuid, user.id, seller_id).await; | |
| 227 | + | } | |
| 219 | 228 | ||
| 220 | 229 | // Clear any prior contact revocation if fan is re-sharing | |
| 221 | 230 | if form.share_contact { | |
| @@ -736,3 +745,53 @@ pub(super) async fn checkout_cancel( | |||
| 736 | 745 | ||
| 737 | 746 | Redirect::to(&redirect_url) | |
| 738 | 747 | } | |
| 748 | + | ||
| 749 | + | /// Grant access to all child items of a purchased bundle. | |
| 750 | + | /// | |
| 751 | + | /// For each child item, creates a completed $0 transaction (idempotent via | |
| 752 | + | /// ON CONFLICT DO NOTHING). Does NOT increment child item sales_count — | |
| 753 | + | /// the bundle sale is what counts. | |
| 754 | + | pub(crate) async fn grant_bundle_items( | |
| 755 | + | state: &AppState, | |
| 756 | + | bundle_id: db::ItemId, | |
| 757 | + | buyer_id: db::UserId, | |
| 758 | + | seller_id: db::UserId, | |
| 759 | + | ) { | |
| 760 | + | let child_items = match db::bundles::get_bundle_items(&state.db, bundle_id).await { | |
| 761 | + | Ok(items) => items, | |
| 762 | + | Err(e) => { | |
| 763 | + | tracing::error!(bundle_id = %bundle_id, error = ?e, "failed to load bundle items for granting"); | |
| 764 | + | return; | |
| 765 | + | } | |
| 766 | + | }; | |
| 767 | + | ||
| 768 | + | let seller = match db::users::get_user_by_id(&state.db, seller_id).await { | |
| 769 | + | Ok(Some(u)) => u, | |
| 770 | + | _ => return, | |
| 771 | + | }; | |
| 772 | + | ||
| 773 | + | for child in &child_items { | |
| 774 | + | let claim = db::transactions::ClaimParams { | |
| 775 | + | buyer_id, | |
| 776 | + | item_id: child.id, | |
| 777 | + | seller_id, | |
| 778 | + | item_title: &child.title, | |
| 779 | + | seller_username: &seller.username, | |
| 780 | + | share_contact: false, | |
| 781 | + | }; | |
| 782 | + | // Idempotent: ON CONFLICT DO NOTHING if already claimed | |
| 783 | + | if let Err(e) = db::transactions::claim_free_item(&state.db, &claim).await { | |
| 784 | + | tracing::warn!( | |
| 785 | + | child_item_id = %child.id, bundle_id = %bundle_id, | |
| 786 | + | error = ?e, "failed to grant bundle child item" | |
| 787 | + | ); | |
| 788 | + | } | |
| 789 | + | // Deliberately NOT incrementing sales_count for child items | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | tracing::info!( | |
| 793 | + | bundle_id = %bundle_id, buyer_id = %buyer_id, | |
| 794 | + | child_count = child_items.len(), | |
| 795 | + | "granted bundle child items" | |
| 796 | + | ); | |
| 797 | + | } |
| @@ -56,6 +56,13 @@ pub(super) async fn handle_purchase_checkout_completed( | |||
| 56 | 56 | ||
| 57 | 57 | // --- Secondary effects below (outside transaction) --- | |
| 58 | 58 | ||
| 59 | + | // Grant access to bundle child items (if this is a bundle) | |
| 60 | + | if let Ok(Some(purchased_item)) = db::items::get_item_by_id(&state.db, item_id).await { | |
| 61 | + | if purchased_item.item_type == db::ItemType::Bundle { | |
| 62 | + | crate::routes::stripe::checkout::grant_bundle_items(state, item_id, buyer_id, seller_id).await; | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 59 | 66 | if tx.share_contact { | |
| 60 | 67 | db::transactions::clear_contact_revocation(&state.db, buyer_id, seller_id).await?; | |
| 61 | 68 | } |
| @@ -70,6 +70,8 @@ pub struct DashboardItemTemplate { | |||
| 70 | 70 | pub promo_codes: Vec<PromoCodeRow>, | |
| 71 | 71 | /// Project labels (display names only) for publish reminder. | |
| 72 | 72 | pub project_labels: Vec<String>, | |
| 73 | + | /// Child items in this bundle (empty for non-bundle items). | |
| 74 | + | pub bundle_items: Vec<Item>, | |
| 73 | 75 | } | |
| 74 | 76 | ||
| 75 | 77 | // ============================================================================ | |
| @@ -313,9 +315,23 @@ pub struct WizardItemDetailsTemplate { | |||
| 313 | 315 | pub struct WizardItemContentTemplate { | |
| 314 | 316 | pub nav: Vec<StepNavItem>, | |
| 315 | 317 | pub project_slug: String, | |
| 318 | + | pub project_id: String, | |
| 316 | 319 | pub item_id: String, | |
| 317 | 320 | pub item_type: String, | |
| 318 | 321 | pub body: String, | |
| 322 | + | /// Non-bundle items in the project available for inclusion. | |
| 323 | + | pub bundleable_items: Vec<BundleableItem>, | |
| 324 | + | /// IDs of items already selected for this bundle. | |
| 325 | + | pub selected_bundle_ids: Vec<String>, | |
| 326 | + | /// IDs of items currently marked unlisted. | |
| 327 | + | pub unlisted_ids: Vec<String>, | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | /// Lightweight item info for the bundle item picker. | |
| 331 | + | pub struct BundleableItem { | |
| 332 | + | pub id: String, | |
| 333 | + | pub title: String, | |
| 334 | + | pub item_type: String, | |
| 319 | 335 | } | |
| 320 | 336 | ||
| 321 | 337 | #[derive(Template)] |
| @@ -232,6 +232,7 @@ pub struct ProjectPaywallTemplate { | |||
| 232 | 232 | /// Public item detail page. | |
| 233 | 233 | #[derive(Template)] | |
| 234 | 234 | #[template(path = "pages/item.html")] | |
| 235 | + | #[allow(dead_code)] // Fields used by Askama template | |
| 235 | 236 | pub struct ItemTemplate { | |
| 236 | 237 | pub csrf_token: CsrfTokenOption, | |
| 237 | 238 | pub session_user: Option<SessionUser>, | |
| @@ -246,6 +247,10 @@ pub struct ItemTemplate { | |||
| 246 | 247 | pub discussion_url: Option<String>, | |
| 247 | 248 | /// Number of posts in the linked discussion thread. | |
| 248 | 249 | pub discussion_count: Option<i64>, | |
| 250 | + | /// Child items for bundle-type items (empty for non-bundles). | |
| 251 | + | pub bundle_items: Vec<Item>, | |
| 252 | + | /// Bundles containing this item (for unlisted items, to show "Available in" links). | |
| 253 | + | pub containing_bundles: Vec<Item>, | |
| 249 | 254 | } | |
| 250 | 255 | ||
| 251 | 256 | /// Blog/article reader view. |
| @@ -254,6 +254,8 @@ impl Item { | |||
| 254 | 254 | pwyw_min_cents: db_item.pwyw_min_cents, | |
| 255 | 255 | publish_at: db_item.publish_at.map(|d| d.format(DATE_FMT_DATETIME_UTC).to_string()), | |
| 256 | 256 | is_public: db_item.is_public, | |
| 257 | + | listed: db_item.listed, | |
| 258 | + | bundle_item_count: 0, | |
| 257 | 259 | } | |
| 258 | 260 | } | |
| 259 | 261 | ||
| @@ -317,6 +319,8 @@ impl Item { | |||
| 317 | 319 | pwyw_min_cents: db_item.pwyw_min_cents, | |
| 318 | 320 | publish_at: db_item.publish_at.map(|d| d.format(DATE_FMT_DATETIME_UTC).to_string()), | |
| 319 | 321 | is_public: db_item.is_public, | |
| 322 | + | listed: db_item.listed, | |
| 323 | + | bundle_item_count: 0, | |
| 320 | 324 | } | |
| 321 | 325 | } | |
| 322 | 326 | } |
| @@ -165,6 +165,9 @@ pub struct Item { | |||
| 165 | 165 | // Scheduled publish | |
| 166 | 166 | pub publish_at: Option<String>, | |
| 167 | 167 | pub is_public: bool, | |
| 168 | + | // Bundle | |
| 169 | + | pub listed: bool, | |
| 170 | + | pub bundle_item_count: i64, | |
| 168 | 171 | } | |
| 169 | 172 | ||
| 170 | 173 | impl Item { |
| @@ -454,7 +454,8 @@ | |||
| 454 | 454 | background: var(--background); | |
| 455 | 455 | } | |
| 456 | 456 | ||
| 457 | - | .upload-area:hover { | |
| 457 | + | .upload-area:hover, | |
| 458 | + | .upload-area.dragover { | |
| 458 | 459 | border-color: var(--highlight); | |
| 459 | 460 | } | |
| 460 | 461 |