Skip to main content

max / makenotwork

13.3 KB · 444 lines History Blame Raw
1 //! What a creator publishes: item and project kinds, the features a project
2 //! can switch on, the AI-disclosure tiers, and how discover sorts it all.
3
4 use super::str_enum::impl_str_enum;
5 use serde::{Deserialize, Serialize};
6
7 // --- Content Insertions ---
8
9 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10 #[serde(rename_all = "snake_case")]
11 pub enum InsertionPosition {
12 PreRoll,
13 MidRoll,
14 PostRoll,
15 }
16
17 impl_str_enum!(InsertionPosition {
18 PreRoll => "pre_roll",
19 MidRoll => "mid_roll",
20 PostRoll => "post_roll",
21 });
22
23 // --- Discover sorting ---
24
25 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26 #[serde(rename_all = "snake_case")]
27 pub enum DiscoverSort {
28 Newest,
29 MostSold,
30 PriceAsc,
31 PriceDesc,
32 }
33
34 impl_str_enum!(DiscoverSort {
35 Newest => "newest",
36 MostSold => "most_sold",
37 PriceAsc => "price_asc",
38 PriceDesc => "price_desc",
39 });
40
41 // --- Items ---
42
43 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
44 #[serde(rename_all = "lowercase")]
45 pub enum ItemType {
46 Audio,
47 Text,
48 Video,
49 Image,
50 Plugin,
51 Preset,
52 Sample,
53 Course,
54 Template,
55 Digital,
56 Bundle,
57 }
58
59 impl_str_enum!(ItemType {
60 Audio => "audio",
61 Text => "text",
62 Video => "video",
63 Image => "image",
64 Plugin => "plugin",
65 Preset => "preset",
66 Sample => "sample",
67 Course => "course",
68 Template => "template",
69 Digital => "digital",
70 Bundle => "bundle",
71 });
72
73 impl ItemType {
74 /// Short human-readable label for display (replaces `helpers::get_item_type_label`).
75 pub fn label(&self) -> &'static str {
76 match self {
77 Self::Audio => "Audio",
78 Self::Text => "Text",
79 Self::Video => "Video",
80 Self::Image => "Image",
81 Self::Plugin => "Plugin",
82 Self::Preset => "Preset",
83 Self::Sample => "Sample",
84 Self::Course => "Course",
85 Self::Template => "Template",
86 Self::Digital => "Digital",
87 Self::Bundle => "Bundle",
88 }
89 }
90
91 /// Which wizard content-input group this type belongs to.
92 ///
93 /// Determines what the content step looks like:
94 /// - `"text"` → Markdown editor
95 /// - `"audio"` → Audio file upload
96 /// - `"video"` → Video file upload
97 /// - `"bundle"` → Item picker for bundle contents
98 /// - `"file"` → Generic file upload
99 pub fn wizard_group(&self) -> &'static str {
100 match self {
101 Self::Text => "text",
102 Self::Audio => "audio",
103 Self::Video => "video",
104 Self::Bundle => "bundle",
105 _ => "file",
106 }
107 }
108 }
109
110 // --- AI Tiers ---
111
112 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
113 #[serde(rename_all = "snake_case")]
114 pub enum AiTier {
115 Handmade,
116 Assisted,
117 Generated,
118 }
119
120 impl_str_enum!(AiTier {
121 Handmade => "handmade",
122 Assisted => "assisted",
123 Generated => "generated",
124 });
125
126 impl AiTier {
127 pub fn label(&self) -> &'static str {
128 match self {
129 Self::Handmade => "Handmade",
130 Self::Assisted => "Assisted",
131 Self::Generated => "Generated",
132 }
133 }
134
135 /// The badge modifier for this tier. A disclosure level is not lifecycle,
136 /// so it keeps its own names rather than joining the status set, but the
137 /// class still comes from here rather than from the serialized value.
138 pub fn css_class(&self) -> &'static str {
139 match self {
140 Self::Handmade => "ai-tier-handmade",
141 Self::Assisted => "ai-tier-assisted",
142 Self::Generated => "ai-tier-generated",
143 }
144 }
145 }
146
147 /// Discover-page filter shape per `about/generative-ai.md` § "How Fans
148 /// Use This". Distinct from `AiTier` because this is a *filter*, not a
149 /// per-item value: `HumanLed` aggregates the Handmade + Assisted tiers.
150 /// `None` on `DiscoverFilters.ai_tier` means "Everything", no
151 /// restriction.
152 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
153 pub enum AiTierFilter {
154 HandmadeOnly,
155 HumanLed,
156 }
157
158 impl_str_enum!(AiTierFilter {
159 HandmadeOnly => "handmade_only",
160 HumanLed => "human_led",
161 });
162
163 impl AiTierFilter {
164 pub fn label(&self) -> &'static str {
165 match self {
166 Self::HandmadeOnly => "Handmade only",
167 Self::HumanLed => "Human-led",
168 }
169 }
170 }
171
172 // --- Project Features ---
173
174 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
175 #[serde(rename_all = "snake_case")]
176 pub enum ProjectFeature {
177 Audio,
178 Downloads,
179 Text,
180 Blog,
181 Subscriptions,
182 LicenseKeys,
183 SourceCode,
184 CloudSync,
185 }
186
187 impl_str_enum!(ProjectFeature {
188 Audio => "audio",
189 Downloads => "downloads",
190 Text => "text",
191 Blog => "blog",
192 Subscriptions => "subscriptions",
193 LicenseKeys => "license_keys",
194 SourceCode => "source_code",
195 CloudSync => "cloud_sync",
196 });
197
198 impl ProjectFeature {
199 /// Human-readable label for display.
200 pub fn label(&self) -> &'static str {
201 match self {
202 Self::Audio => "Audio",
203 Self::Downloads => "Downloads",
204 Self::Text => "Text",
205 Self::Blog => "Blog",
206 Self::Subscriptions => "Subscriptions",
207 Self::LicenseKeys => "License Keys",
208 Self::SourceCode => "Source Code",
209 Self::CloudSync => "Cloud Sync",
210 }
211 }
212
213 /// One-line description of what this feature enables.
214 pub fn description(&self) -> &'static str {
215 match self {
216 Self::Audio => "Upload and stream audio files. Player with chapters.",
217 Self::Downloads => "Host file downloads with versioned releases.",
218 Self::Text => "Write and publish text content with markdown.",
219 Self::Blog => "Project blog with RSS feed.",
220 Self::Subscriptions => "Monthly subscriber tiers.",
221 Self::LicenseKeys => "Software license management with activation API.",
222 Self::SourceCode => "Git repository with source browser.",
223 Self::CloudSync => "E2E encrypted cloud sync for desktop and mobile apps.",
224 }
225 }
226
227 /// All features as (value, label, description) tuples for form rendering.
228 pub fn all() -> &'static [(&'static str, &'static str, &'static str)] {
229 &[
230 (
231 "audio",
232 "Audio",
233 "Upload and stream audio files. Player with chapters.",
234 ),
235 (
236 "downloads",
237 "Downloads",
238 "Host file downloads with versioned releases.",
239 ),
240 (
241 "text",
242 "Text",
243 "Write and publish text content with markdown.",
244 ),
245 ("blog", "Blog", "Project blog with RSS feed."),
246 (
247 "subscriptions",
248 "Subscriptions",
249 "Monthly subscriber tiers.",
250 ),
251 (
252 "license_keys",
253 "License Keys",
254 "Software license management with activation API.",
255 ),
256 (
257 "source_code",
258 "Source Code",
259 "Git repository with source browser.",
260 ),
261 (
262 "cloud_sync",
263 "Cloud Sync",
264 "E2E encrypted cloud sync for desktop and mobile apps.",
265 ),
266 ]
267 }
268
269 /// Derive the best-fit project type from a set of features.
270 pub fn derive_project_type(features: &[String]) -> ProjectType {
271 if features.iter().any(|f| f == "audio") {
272 return ProjectType::Music;
273 }
274 if features.iter().any(|f| f == "text") && !features.iter().any(|f| f == "downloads") {
275 return ProjectType::Blog;
276 }
277 if features.iter().any(|f| f == "downloads") {
278 return ProjectType::Software;
279 }
280 ProjectType::General
281 }
282
283 /// Which item types a feature unlocks.
284 pub fn allowed_item_types(&self) -> &'static [ItemType] {
285 match self {
286 Self::Audio => &[ItemType::Audio, ItemType::Sample, ItemType::Preset],
287 Self::Downloads => &[
288 ItemType::Digital,
289 ItemType::Plugin,
290 ItemType::Template,
291 ItemType::Course,
292 ItemType::Image,
293 ItemType::Video,
294 ],
295 Self::Text => &[ItemType::Text],
296 // Non-content features don't gate item types
297 Self::Blog
298 | Self::Subscriptions
299 | Self::LicenseKeys
300 | Self::SourceCode
301 | Self::CloudSync => &[],
302 }
303 }
304
305 /// Compute the set of item types allowed by a project's feature list.
306 /// If no content features are enabled, all types are allowed (permissive default).
307 pub fn allowed_item_type_cards(
308 features: &[String],
309 ) -> Vec<(&'static str, &'static str, &'static str)> {
310 let allowed: std::collections::HashSet<ItemType> = features
311 .iter()
312 .filter_map(|f| f.parse::<ProjectFeature>().ok())
313 .flat_map(|f| f.allowed_item_types().iter().copied())
314 .collect();
315
316 // If no content features enabled, show all types (backwards compat)
317 if allowed.is_empty() {
318 return Self::all_item_type_cards().to_vec();
319 }
320
321 Self::all_item_type_cards()
322 .iter()
323 .filter(|(value, _, _)| {
324 value
325 .parse::<ItemType>()
326 .is_ok_and(|t| t == ItemType::Bundle || allowed.contains(&t))
327 })
328 .copied()
329 .collect()
330 }
331
332 /// All item type cards: (value, label, description) tuples for form rendering.
333 pub fn all_item_type_cards() -> &'static [(&'static str, &'static str, &'static str)] {
334 &[
335 ("audio", "Audio", "Podcast, music, sound effects"),
336 ("text", "Text", "Articles, posts, essays, guides"),
337 ("digital", "Digital Download", "Files, archives, documents"),
338 ("video", "Video", "Tutorials, films, recordings"),
339 ("course", "Course", "Multi-part lessons, curricula"),
340 ("plugin", "Plugin", "Software extensions, add-ons"),
341 ("sample", "Sample Pack", "Audio samples, loops, one-shots"),
342 ("preset", "Preset Pack", "Synth presets, effect chains"),
343 ("template", "Template", "Design templates, starter kits"),
344 ("image", "Image", "Photos, artwork, graphics"),
345 ("bundle", "Bundle", "Collection of other items"),
346 ]
347 }
348
349 /// Item type cards filtered to one per distinct wizard behavior group.
350 ///
351 /// The wizard only needs a type selector when the allowed types produce
352 /// different content-step UIs (text editor vs audio upload vs file upload).
353 /// Returns one card per group, using the first allowed type as the value.
354 /// If all types share one group, returns a single card (caller should skip
355 /// the type step entirely).
356 pub fn wizard_type_cards(
357 features: &[String],
358 ) -> Vec<(&'static str, &'static str, &'static str)> {
359 let allowed = Self::allowed_item_type_cards(features);
360 let mut seen_groups = std::collections::HashSet::new();
361 let mut cards = Vec::new();
362
363 for (value, _, _) in &allowed {
364 let Ok(item_type) = value.parse::<ItemType>() else {
365 continue;
366 };
367 let group = item_type.wizard_group();
368 if seen_groups.insert(group) {
369 let (label, desc) = match group {
370 "text" => ("Text", "Write in the editor"),
371 "audio" => ("Audio", "Upload audio files"),
372 "video" => ("Video", "Upload video files"),
373 "bundle" => ("Bundle", "Collection of other items"),
374 _ => ("File", "Upload any file"),
375 };
376 cards.push((*value, label, desc));
377 }
378 }
379
380 cards
381 }
382 }
383
384 // --- Projects ---
385
386 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
387 #[serde(rename_all = "lowercase")]
388 pub enum ProjectType {
389 Blog,
390 Book,
391 Podcast,
392 Course,
393 Music,
394 Software,
395 Art,
396 Writing,
397 #[default]
398 General,
399 }
400
401 impl_str_enum!(ProjectType {
402 Blog => "blog",
403 Book => "book",
404 Podcast => "podcast",
405 Course => "course",
406 Music => "music",
407 Software => "software",
408 Art => "art",
409 Writing => "writing",
410 General => "general",
411 });
412
413 impl ProjectType {
414 /// Human-readable label for display.
415 pub fn label(&self) -> &'static str {
416 match self {
417 Self::Blog => "Blog",
418 Self::Book => "Book",
419 Self::Podcast => "Podcast",
420 Self::Course => "Course",
421 Self::Music => "Music",
422 Self::Software => "Software",
423 Self::Art => "Art",
424 Self::Writing => "Writing",
425 Self::General => "General",
426 }
427 }
428
429 /// All valid project types as (value, label) pairs for form rendering.
430 pub fn all() -> &'static [(&'static str, &'static str)] {
431 &[
432 ("blog", "Blog"),
433 ("book", "Book"),
434 ("podcast", "Podcast"),
435 ("course", "Course"),
436 ("music", "Music"),
437 ("software", "Software"),
438 ("art", "Art"),
439 ("writing", "Writing"),
440 ("general", "General"),
441 ]
442 }
443 }
444