Skip to main content

max / makenotwork

33.2 KB · 912 lines History Blame Raw
1 //! The public project page, described.
2 //!
3 //! `/p/{slug}`: the cover, who made it, what is in it, and every way to follow,
4 //! read, subscribe to or support it. It replaces `templates/pages/project.html`
5 //! and `ProjectTemplate`, and it is the last of the three pages that wrote
6 //! `<link rel="alternate">` by hand.
7 //!
8 //! # Why the route stays an axum handler
9 //!
10 //! [`super::project_blog`]'s reason, at the page it is largest for. The handler
11 //! resolves a slug, runs the project-level paywall gate, loads items, tiers,
12 //! git repos, sections, gallery frames and follow state, and records a page
13 //! view unless the caller is a crawler. `super::Viewer` carries no session and
14 //! none of that wants a described route. What is described is the document.
15 //!
16 //! # Four behaviours the vocabulary took over
17 //!
18 //! - **The section tabs.** `.section-tab` buttons over `.section-panel` divs,
19 //! switched by `static/page-project.js`. A [`RegionKind::TabGroup`] whose
20 //! children carry a [`Slot::label`] is that, and `quasi-webview` writes the
21 //! strip and the program that moves it. The script is gone.
22 //! - **The gallery.** An Askama macro over the shipped widget. The described
23 //! half of the same widget is [`super::widgets::carousel::region`], so the
24 //! gallery here and the three templates still calling the macro cannot be two
25 //! different carousels. Its frames are ordinary pictures, so a host that has
26 //! never heard of a carousel still draws every one of them.
27 //! - **The tip offer's disclosure**, which is [`super::tip`]'s.
28 //! - **The share link**, which is [`quasi_router::Act::copies`].
29 //!
30 //! # Three that did not survive, and why
31 //!
32 //! **The grid/list toggle.** Two `.view-btn`s over two copies of the same
33 //! items, with the choice kept in `localStorage` by
34 //! `static/page-project-2.js`. The list copy carried strictly less than the
35 //! grid copy -- a title, a type and a price against a cover, tags, a
36 //! description, a date and a sales count -- so what the toggle offered was one
37 //! full view and one lossy one. There is no member for how densely a list is
38 //! drawn and there should not be: that is the renderer's, which is the whole
39 //! premise. One described list, drawn once.
40 //!
41 //! **`.follow-btn.is-selected`**, for [`super::follow`]'s reason.
42 //!
43 //! **The promo code's `<details>`.** One optional input behind a summary, per
44 //! tier. Said as a field with the placeholder the template wrote, because a
45 //! [`Reveal`](quasi_router::Reveal) names its control by name and every tier
46 //! card on the page would have named the same one.
47 //!
48 //! # What is still markup, and who owes it
49 //!
50 //! **The section bodies.** [`Node::rich`] carries markdown and quasi renders
51 //! it, and a project section's markdown has been through
52 //! [`crate::markdown::render_creator_markdown`] first: media paths rewritten
53 //! against the CDN under the creator's own id, off-platform media hosts
54 //! stripped, and `<img>` of a video file turned into a `<video>`. None of that
55 //! is derivable from the source, so handing quasi the source would silently
56 //! drop three transforms a creator's page depends on. The panels are
57 //! [`RegionKind::Handover`]s and this module fills them; the *strip* over them
58 //! is described, which is the half that was a script.
59 //!
60 //! **The report modal.** A dialogue a control opens, and the vocabulary's
61 //! [`RegionKind::Modal`] is a modal that arrives with the screen. The markup is
62 //! still `templates/partials/report_modal.html`, rendered through
63 //! [`crate::templates::ReportModalTemplate`], so `pages/item.html` and this
64 //! page cannot drift: one file, two callers.
65
66 use makeover_layout as layout;
67 use quasi_router::screen::{Field, Image, Row};
68 use quasi_router::{
69 Act, Action, Document, Feed, FeedKind, Node, RegionKind, Screen as Described, Slot, Tag,
70 };
71 use quasi_webview::Webview;
72
73 use crate::templates::CarouselFrame;
74 use crate::types::{Item, Project, ProjectSection, SubscriptionTier};
75
76 /// The page's own region, and what the skip link points at.
77 pub const PAGE_REGION: &str = "project-store";
78
79 /// The tab group the creator's own sections sit in.
80 const SECTIONS_REGION: &str = "project-sections";
81
82 /// The carousel the gallery is.
83 const GALLERY_REGION: &str = "project-gallery";
84
85 /// The place the report dialogue is handed over in.
86 ///
87 /// The id `partials/report_modal.html` writes and `actions-partials.js` looks
88 /// for, so the control that opens it keeps working without either side being
89 /// told about the other.
90 const REPORT_REGION: &str = "report-modal";
91
92 /// How wide it runs. The template wrote this on the body.
93 const MEASURE: layout::Measure = layout::Measure::Wide;
94
95 /// Where a project's feed answers.
96 #[must_use]
97 pub fn feed_path(slug: &str) -> String {
98 format!("/p/{slug}/rss")
99 }
100
101 /// Everything the screen is about.
102 ///
103 /// A struct rather than twenty arguments, for [`super::user::Profile`]'s
104 /// reason.
105 pub struct Store<'a> {
106 /// The project.
107 pub project: &'a Project,
108 /// Its id, which is what the follow route takes.
109 pub project_id: &'a str,
110 /// Who made it.
111 pub creator_username: &'a str,
112 /// Their id, which is what the tip route takes.
113 pub creator_id: &'a str,
114 /// The site's own base address, for the absolute URLs a crawler and a share
115 /// sheet read.
116 pub host_url: &'a str,
117 /// What is published in it.
118 pub items: &'a [Item],
119 /// The creator's own tabbed sections.
120 pub sections: &'a [ProjectSection],
121 /// The gallery, beside the cover rather than instead of it.
122 pub gallery: &'a [CarouselFrame],
123 /// The tiers a reader may subscribe to.
124 pub tiers: &'a [SubscriptionTier],
125 /// Linked repositories, as name and address.
126 pub git_repos: &'a [(String, String)],
127 /// The paired forum, when one is provisioned.
128 pub community_url: Option<&'a str>,
129 /// How many people follow it.
130 pub follower_count: i64,
131 /// Whether the viewer does.
132 pub is_following: bool,
133 /// Whether the viewer already subscribes.
134 pub has_subscription: bool,
135 /// Whether the viewer owns it.
136 pub is_owner: bool,
137 /// Whether there is a session at all.
138 pub signed_in: bool,
139 /// Whether the project has anything on its blog.
140 pub has_blog_posts: bool,
141 /// Whether the creator takes tips.
142 pub tips_enabled: bool,
143 }
144
145 impl Store<'_> {
146 /// The address this page answers at.
147 fn canonical(&self) -> String {
148 format!("{}/p/{}", self.host_url, self.project.slug)
149 }
150
151 /// The picture a link preview shows: the cover, or the site's card.
152 fn image(&self) -> String {
153 self.project
154 .cover_image_url
155 .clone()
156 .filter(|url| !url.trim().is_empty())
157 .unwrap_or_else(|| format!("{}/static/images/og-card.png", self.host_url))
158 }
159
160 /// The structured data the template opened a `CollectionPage` block with.
161 ///
162 /// Escaped for JSON rather than for HTML, which is what it is: a JSON
163 /// document inside a `<script>`, where `&gt;` would be four characters in a
164 /// string rather than an escape.
165 fn structured_data(&self) -> String {
166 let json = format!(
167 "{{\"@context\":\"https://schema.org\",\"@type\":\"CollectionPage\",\
168 \"name\":\"{}\",\"description\":\"{}\",\"url\":\"{}\",\
169 \"author\":{{\"@type\":\"Person\",\"name\":\"{}\",\"url\":\"{}\"}},\
170 \"numberOfItems\":{},\
171 \"isPartOf\":{{\"@type\":\"WebSite\",\"name\":\"Makenotwork\",\"url\":\"{}\"}}}}",
172 self.project.title_json(),
173 self.project.description_json(),
174 crate::types::json_escape(&self.canonical()),
175 crate::types::json_escape(self.creator_username),
176 crate::types::json_escape(&format!("{}/u/{}", self.host_url, self.creator_username)),
177 self.project.item_count,
178 crate::types::json_escape(self.host_url),
179 );
180 format!("<script type=\"application/ld+json\">{json}</script>")
181 }
182 }
183
184 /// One item, as a row.
185 ///
186 /// Everything the card carried, in the roles the vocabulary has for them: the
187 /// cover and the title are what the row is called, the type and the date are
188 /// its meta, the description is its secondary line, the tags are its tokens,
189 /// and the way to get it is what it offers.
190 fn item_row(item: &Item) -> Row {
191 let destination = if item.can_access {
192 format!("/i/{}", item.id)
193 } else {
194 format!("/purchase/{}", item.id)
195 };
196
197 let mut row = Row::default();
198 if let Some(cover) = item.cover() {
199 row = row.part(
200 layout::RowPart::Primary,
201 Node::Image(Image::new(cover, item.title.clone()).lazy()),
202 );
203 }
204 row = row
205 .part(layout::RowPart::Primary, Node::text(item.title.clone()))
206 .meta(if item.bundle_item_count > 0 {
207 format!(
208 "{} ({} items) - {}",
209 item.item_type, item.bundle_item_count, item.release_date
210 )
211 } else {
212 format!("{} - {}", item.item_type, item.release_date)
213 })
214 .secondary(item.description.clone())
215 .relaxed();
216
217 for tag in &item.tags {
218 row = row.token(Tag::chip(
219 tag.name.clone(),
220 Action::get("/discover")
221 .carrying("tag", tag.slug.clone())
222 .navigating(),
223 ));
224 }
225
226 row = row
227 .part(layout::RowPart::Meta, Node::text(item.price.clone()))
228 .part(
229 layout::RowPart::Meta,
230 Node::text(format!("{} sales", item.sales_count)),
231 )
232 .activate(Action::get(&destination).navigating());
233
234 // The one way in, per state. The template drew four and so does this.
235 if item.can_access {
236 row.act(Act::new(
237 "View in library",
238 Action::get(format!("/l/{}", item.id)).navigating(),
239 ))
240 } else if item.is_free {
241 row.act(Act::new(
242 "Add to Library",
243 Action::post(format!("/api/library/add/{}", item.id)).invalidating(),
244 ))
245 } else if item.pwyw_enabled {
246 row.act(Act::new(
247 "Pay What You Want",
248 Action::get(&destination).navigating(),
249 ))
250 } else {
251 row.act(Act::new("Buy Once", Action::get(&destination).navigating()))
252 }
253 }
254
255 /// One membership tier, as a group.
256 fn tier_group(store: &Store<'_>, tier: &SubscriptionTier) -> Node {
257 let mut group = Slot::new(format!("tier-{}", tier.id), RegionKind::Group)
258 .with(Node::section(tier.name.clone()))
259 .with(Node::text(tier.price.clone()));
260
261 if !tier.description.is_empty() {
262 group = group.with(Node::text(tier.description.clone()));
263 }
264
265 group = if store.has_subscription {
266 group.with(Node::Token(Tag::badge("Subscribed")))
267 } else if store.signed_in {
268 let mut promo = Field::new(
269 layout::FieldKind::Text,
270 "promo_code",
271 "Promo code (optional)",
272 );
273 promo.placeholder = Some("e.g. TRIAL14".to_owned());
274 group.with(Node::Form {
275 // The hidden `_csrf` goes the way the tip form's did: this arrives
276 // as an htmx post and `frontend/src/core/htmx-glue.ts` attaches the
277 // token from the document's meta. `create_subscription_checkout`
278 // ends at Stripe, so it answers `HX-Redirect` to an htmx caller.
279 action: Action::post(format!("/stripe/subscribe/{}", tier.id)),
280 submit: "Subscribe".to_owned(),
281 fields: vec![promo],
282 })
283 } else {
284 group.with(Node::act(
285 "Log in to Subscribe",
286 Action::get("/login").navigating(),
287 ))
288 };
289
290 Node::Region(group)
291 }
292
293 /// The whole document.
294 #[must_use]
295 pub fn screen(store: &Store<'_>, theme_css: &str) -> Described {
296 let project = store.project;
297 let feed = feed_path(&project.slug);
298
299 let mut page = Slot::new(PAGE_REGION, RegionKind::Pane);
300
301 if let Some(cover) = project
302 .cover_image_url
303 .as_deref()
304 .map(str::trim)
305 .filter(|url| !url.is_empty())
306 {
307 page = page.with(Node::Image(Image::new(cover, project.title.clone())));
308 }
309
310 page = page
311 .with(Node::page(project.title.clone()))
312 .with(Node::Link {
313 text: store.creator_username.to_owned(),
314 action: Action::get(format!("/u/{}", store.creator_username)).navigating(),
315 })
316 .with(Node::text(format!("{} items", project.item_count)))
317 .with(Node::text(project.description.clone()));
318
319 if store.is_owner {
320 page = page.with(Node::act(
321 "Edit Project",
322 Action::get(format!("/dashboard/project/{}", project.slug)).navigating(),
323 ));
324 }
325
326 if store.signed_in {
327 page = page.with(super::follow::control(
328 "project",
329 store.project_id,
330 store.is_following,
331 store.follower_count,
332 ));
333 } else if let Some(count) = super::follow::count_only(store.follower_count) {
334 page = page.with(count);
335 }
336
337 page = page.with(Node::act(
338 "RSS Feed",
339 Action::get(feed.clone()).navigating(),
340 ));
341
342 if store.has_blog_posts {
343 page = page.with(Node::act(
344 "Blog",
345 Action::get(format!("/p/{}/blog", project.slug)).navigating(),
346 ));
347 }
348
349 for (name, url) in store.git_repos {
350 page = page.with(Node::act(
351 format!("Git ({name})"),
352 Action::get(url.clone()).navigating(),
353 ));
354 }
355
356 if let Some(url) = store.community_url {
357 // The forum is a separate deployment on its own host, and a reader is
358 // expected to come back, which is what `External` says.
359 page = page.with(Node::act("Community", Action::external(url.to_owned())));
360 }
361
362 if store.tips_enabled {
363 page = page.with(super::tip::control(&super::tip::Offer {
364 creator_id: store.creator_id,
365 project_id: Some(store.project_id),
366 signed_in: store.signed_in,
367 }));
368 }
369
370 if !store.gallery.is_empty() {
371 // The shipped widget rather than a fourth hand-rolled carousel:
372 // `super::widgets::carousel` is the one place a template frame becomes
373 // a described one, and the Askama macro the item page still calls goes
374 // through it too.
375 page = page.with(super::widgets::carousel::region(
376 GALLERY_REGION,
377 store.gallery,
378 ));
379 }
380
381 if !store.sections.is_empty() {
382 let mut tabs = Slot::new(SECTIONS_REGION, RegionKind::TabGroup).showing_one(0);
383 for section in store.sections {
384 tabs = tabs.with(Node::Region(
385 Slot::handover(panel_region(&section.slug), "a project section")
386 .label(section.title.clone()),
387 ));
388 }
389 page = page.with(Node::Region(tabs));
390 }
391
392 page = page
393 .with(Node::section("Available Items"))
394 .with(if store.items.is_empty() {
395 Node::empty("Nothing published here yet.")
396 } else {
397 Node::list(store.items.iter().map(item_row))
398 });
399
400 if !store.tiers.is_empty() {
401 page = page.with(Node::section("Membership"));
402 for tier in store.tiers {
403 page = page.with(tier_group(store, tier));
404 }
405 }
406
407 page = page
408 .with(Node::Link {
409 text: "Powered by Makenot.work".to_owned(),
410 action: Action::get("/").navigating(),
411 })
412 .with(Node::text(
413 "Fair distribution for creatives of all kinds".to_owned(),
414 ))
415 .with(Node::Act(
416 Act::new("Copy link", Action::local()).copying(store.canonical()),
417 ))
418 .with(Node::Link {
419 text: "Policy".to_owned(),
420 action: Action::get("/policy").navigating(),
421 });
422
423 // Reporting is a write, so it needs a session. Signed out, the offer is the
424 // way to get one, which is what the template drew.
425 if store.signed_in {
426 page = page.with(Node::Region(Slot::handover(
427 REPORT_REGION,
428 "the report dialogue",
429 )));
430 } else {
431 page = page.with(Node::Link {
432 text: "Report".to_owned(),
433 action: Action::get("/login").navigating(),
434 });
435 }
436
437 Described::single(format!("{} - {}", project.title, store.creator_username))
438 .measured(MEASURE)
439 .documented(
440 Document::default()
441 .classed(crate::shell::body_class(MEASURE, &["project-page"]))
442 // Tier 0, as `super::user` carries the creator's.
443 .styled(theme_css.to_owned()),
444 )
445 .summarised(project.description.clone())
446 .illustrated(store.image())
447 .canonical_at(store.canonical())
448 .about(quasi_router::SocialKind::Product)
449 .syndicating(Feed::new(
450 FeedKind::Rss,
451 format!("{} - RSS Feed", project.title),
452 feed,
453 ))
454 .with(page)
455 }
456
457 /// The id of the region one section's body is handed over in.
458 ///
459 /// `section-<slug>` because that is what `style.css` matched and what the
460 /// script's `history.replaceState` wrote into the fragment, so an old link into
461 /// a section still lands on it.
462 fn panel_region(slug: &str) -> String {
463 format!("section-{slug}")
464 }
465
466 /// The document this screen is drawn in, with every handover paid.
467 #[must_use]
468 pub fn renderer(
469 viewer: Option<&crate::auth::SessionUser>,
470 csrf: Option<&str>,
471 store: &Store<'_>,
472 ) -> Webview {
473 let csrf = csrf.unwrap_or_default();
474 let mut webview = Webview::new().with_shell(
475 crate::shell::described()
476 .sending("X-CSRF-Token", csrf)
477 .with_body_last(crate::shell::body_last())
478 .with_body_first(format!(
479 "{}{}",
480 crate::shell::skip_link(PAGE_REGION),
481 crate::shell::site_header(viewer)
482 ))
483 .with_head(format!(
484 "<meta name=\"csrf-token\" content=\"{}\">{}",
485 crate::helpers::escape_html(csrf),
486 store.structured_data()
487 )),
488 );
489
490 for section in store.sections {
491 webview = webview.with_fill(panel_region(&section.slug), section.body_html.clone());
492 }
493
494 if store.signed_in {
495 webview = webview.with_fill(REPORT_REGION, report_markup(store.project_id));
496 }
497
498 webview
499 }
500
501 /// The report dialogue, from the file `pages/item.html` still includes.
502 ///
503 /// One copy of the markup, two callers. A failure to render is an empty fill
504 /// rather than a failure to draw the page: the dialogue is one control on a
505 /// storefront, and losing the storefront over it is the wrong trade.
506 fn report_markup(project_id: &str) -> String {
507 crate::templates::ReportModalTemplate {
508 report_target_type: "project",
509 report_target_id: project_id.to_owned(),
510 report_has_labels: true,
511 }
512 .render_string()
513 .unwrap_or_default()
514 }
515
516 /// Render it.
517 #[must_use]
518 pub fn document(
519 viewer: Option<&crate::auth::SessionUser>,
520 csrf: Option<&str>,
521 store: &Store<'_>,
522 theme_css: &str,
523 ) -> String {
524 use quasi_axum::Serves as _;
525
526 let screen = screen(store, theme_css);
527 renderer(viewer, csrf, store).screen(&screen)
528 }
529
530 #[cfg(test)]
531 mod tests {
532 use super::*;
533 use crate::types::TagView;
534
535 fn project() -> Project {
536 Project {
537 id: "p1".to_owned(),
538 slug: "blue-hour".to_owned(),
539 title: "Blue Hour".to_owned(),
540 description: "Field recordings".to_owned(),
541 item_count: 2,
542 project_type: "Music".to_owned(),
543 cover_image_url: None,
544 }
545 }
546
547 fn item(id: &str) -> Item {
548 Item {
549 id: id.to_owned(),
550 title: format!("Track {id}"),
551 price: "$5".to_owned(),
552 price_cents: 500,
553 item_type: "Audio".to_owned(),
554 description: "A recording".to_owned(),
555 thumbnail: "Audio".to_owned(),
556 release_date: "2026-08-01".to_owned(),
557 sales_count: 7,
558 tags: vec![TagView {
559 id: "t1".to_owned(),
560 name: "Ambient".to_owned(),
561 slug: "ambient".to_owned(),
562 is_primary: true,
563 }],
564 content: crate::types::ItemContent::Text {
565 body: None,
566 body_html: None,
567 reading_time: None,
568 reading_time_minutes: None,
569 word_count: None,
570 },
571 cover_image_url: None,
572 is_free: false,
573 can_access: false,
574 enable_license_keys: false,
575 default_max_activations: None,
576 pwyw_enabled: false,
577 pwyw_min_cents: None,
578 publish_at: None,
579 is_public: true,
580 listed: true,
581 bundle_item_count: 0,
582 license_preset: None,
583 custom_license_text: None,
584 ai_tier: crate::db::AiTier::Handmade,
585 ai_disclosure: None,
586 }
587 }
588
589 fn store<'a>(project: &'a Project, items: &'a [Item]) -> Store<'a> {
590 Store {
591 project,
592 project_id: "p1",
593 creator_username: "maxj",
594 creator_id: "u1",
595 host_url: "https://makenot.work",
596 items,
597 sections: &[],
598 gallery: &[],
599 tiers: &[],
600 git_repos: &[],
601 community_url: None,
602 follower_count: 0,
603 is_following: false,
604 has_subscription: false,
605 is_owner: false,
606 signed_in: false,
607 has_blog_posts: false,
608 tips_enabled: false,
609 }
610 }
611
612 fn html(store: &Store<'_>) -> String {
613 document(None, Some("t"), store, ":root{--x:1}")
614 }
615
616 /// The last of the three hand-written tags, said by the screen, spelled by
617 /// the vocabulary, and emitted once.
618 #[test]
619 fn the_feed_is_declared_once_and_spelled_by_the_vocabulary() {
620 let project = project();
621 let screen = screen(&store(&project, &[]), "");
622 let feed = screen.discovery.feed.as_ref().expect("declared");
623 assert_eq!(feed.href, "/p/blue-hour/rss");
624 assert_eq!(feed.title, "Blue Hour - RSS Feed");
625
626 let rendered = html(&store(&project, &[]));
627 assert!(
628 rendered.contains(
629 "<link rel=\"alternate\" type=\"application/rss+xml\" \
630 title=\"Blue Hour - RSS Feed\" href=\"/p/blue-hour/rss\">"
631 ),
632 "{rendered}"
633 );
634 assert_eq!(
635 rendered.matches("rel=\"alternate\"").count(),
636 1,
637 "{rendered}"
638 );
639 assert!(rendered.contains(">RSS Feed<"), "{rendered}");
640 }
641
642 /// The creator's Tier 0 sheet lands after every stylesheet the shell
643 /// carries, which is what the unlayered `<style>` block held.
644 #[test]
645 fn the_project_theme_outranks_the_site_stylesheets() {
646 let project = project();
647 let rendered = document(
648 None,
649 Some("t"),
650 &store(&project, &[]),
651 ":root{--accent:red}",
652 );
653 let theme = rendered.find(":root{--accent:red}").expect("written");
654 let last_sheet = rendered.rfind("/static/style.css").expect("linked");
655 assert!(theme > last_sheet, "the theme landed before style.css");
656 }
657
658 /// The `CollectionPage` block the template opened, built from the same
659 /// facts and escaped for JSON rather than for HTML.
660 #[test]
661 fn the_page_still_describes_itself_to_a_crawler() {
662 let project = project();
663 let rendered = html(&store(&project, &[]));
664 assert!(
665 rendered.contains("\"@type\":\"CollectionPage\""),
666 "{rendered}"
667 );
668 assert!(rendered.contains("\"numberOfItems\":2"), "{rendered}");
669 assert!(
670 rendered.contains("\"url\":\"https://makenot.work/u/maxj\""),
671 "{rendered}"
672 );
673 }
674
675 /// Everything an item card carried is still on its row, and the row still
676 /// goes where the card went.
677 #[test]
678 fn an_item_keeps_everything_its_card_carried() {
679 let project = project();
680 let items = [item("i1")];
681 let rendered = html(&store(&project, &items));
682 for fact in [
683 "Track i1",
684 "Audio",
685 "2026-08-01",
686 "A recording",
687 "$5",
688 "7 sales",
689 "Ambient",
690 "/purchase/i1",
691 ] {
692 assert!(rendered.contains(fact), "{fact} is missing: {rendered}");
693 }
694 assert!(rendered.contains("tag=ambient"), "{rendered}");
695 }
696
697 /// The four ways in, one per state, which is what the template drew.
698 #[test]
699 fn every_item_state_offers_the_one_control_it_should() {
700 let project = project();
701
702 let mut owned = item("i1");
703 owned.can_access = true;
704 let rendered = html(&store(&project, &[owned]));
705 assert!(rendered.contains("/l/i1"), "{rendered}");
706 assert!(rendered.contains("View in library"), "{rendered}");
707
708 let mut free = item("i2");
709 free.is_free = true;
710 let rendered = html(&store(&project, &[free]));
711 assert!(rendered.contains("Add to Library"), "{rendered}");
712 assert!(rendered.contains("/api/library/add/i2"), "{rendered}");
713
714 let mut pwyw = item("i3");
715 pwyw.pwyw_enabled = true;
716 assert!(html(&store(&project, &[pwyw])).contains("Pay What You Want"));
717
718 assert!(html(&store(&project, &[item("i4")])).contains("Buy Once"));
719 }
720
721 /// Claiming a free item changes the row it was pressed in -- the item is
722 /// owned now -- and a row is not a region a control can answer into. The
723 /// screen says the surface is stale instead, which is what
724 /// `Replaces::Everything` is for, and the reader gets a card that offers
725 /// the library rather than a status line stuck in a card that still says
726 /// "Add".
727 #[test]
728 fn claiming_a_free_item_says_the_page_is_stale() {
729 let project = project();
730 let mut free = item("i1");
731 free.is_free = true;
732 let rendered = html(&store(&project, &[free]));
733 assert!(!rendered.contains("save-status"), "{rendered}");
734 assert!(rendered.contains("/api/library/add/i1"), "{rendered}");
735 }
736
737 /// The strip is described and the panels are handed over, which is the
738 /// split: the switching was a script and the bodies are creator markdown
739 /// that has been through this server's own media pass.
740 #[test]
741 fn the_sections_get_a_described_strip_and_their_own_markup() {
742 let project = project();
743 let sections = [
744 ProjectSection {
745 id: "s1".to_owned(),
746 title: "Privacy".to_owned(),
747 slug: "privacy".to_owned(),
748 body: "raw".to_owned(),
749 body_html: "<p>Rendered <video src=\"x\"></video></p>".to_owned(),
750 sort_order: 0,
751 },
752 ProjectSection {
753 id: "s2".to_owned(),
754 title: "Terms".to_owned(),
755 slug: "terms".to_owned(),
756 body: "raw".to_owned(),
757 body_html: "<p>Second</p>".to_owned(),
758 sort_order: 1,
759 },
760 ];
761 let mut with_sections = store(&project, &[]);
762 with_sections.sections = &sections;
763 let rendered = html(&with_sections);
764
765 // Both panels keep the id an old fragment link points at.
766 assert!(rendered.contains("id=\"section-privacy\""), "{rendered}");
767 assert!(rendered.contains("id=\"section-terms\""), "{rendered}");
768 // Both are filled with the markup this server rendered, untouched.
769 assert!(rendered.contains("<video src=\"x\"></video>"), "{rendered}");
770 assert!(rendered.contains("<p>Second</p>"), "{rendered}");
771 // The strip is the vocabulary's, so nothing calls the deleted script.
772 assert!(rendered.contains("role=\"tablist\""), "{rendered}");
773 assert!(!rendered.contains("onSwitchSectionTab"), "{rendered}");
774 assert!(!rendered.contains("page-project.js"), "{rendered}");
775 }
776
777 /// The gallery is a named assembly, and its frames are ordinary pictures,
778 /// so a host that has never heard of a carousel still draws all of them.
779 #[test]
780 fn the_gallery_is_a_carousel_of_plain_pictures() {
781 let project = project();
782 let gallery = [
783 CarouselFrame {
784 image: "/a.png".to_owned(),
785 alt: "First".to_owned(),
786 caption: None,
787 intrinsic: Some((800, 600)),
788 },
789 CarouselFrame {
790 image: "/b.png".to_owned(),
791 alt: "Second".to_owned(),
792 caption: Some("Two".to_owned()),
793 intrinsic: None,
794 },
795 ];
796 let mut with_gallery = store(&project, &[]);
797 with_gallery.gallery = &gallery;
798 let rendered = html(&with_gallery);
799 assert!(rendered.contains("data-widget=\"carousel\""), "{rendered}");
800 assert!(rendered.contains("/a.png"), "{rendered}");
801 assert!(rendered.contains("/b.png"), "{rendered}");
802 // The one frame whose size this server knows reserves its space.
803 assert!(rendered.contains("width=\"800\""), "{rendered}");
804 }
805
806 /// A tier's form carries no hidden token: the header does, and
807 /// `create_subscription_checkout` answers `HX-Redirect` to an htmx caller.
808 #[test]
809 fn a_tier_is_offered_by_state_and_carries_no_hidden_token() {
810 let project = project();
811 let tiers = [SubscriptionTier {
812 id: "t1".to_owned(),
813 name: "Patron".to_owned(),
814 description: "Everything".to_owned(),
815 price: "$5/mo".to_owned(),
816 price_cents: 500,
817 is_active: true,
818 sort_order: 0,
819 has_stripe_price: true,
820 }];
821
822 let mut signed_out = store(&project, &[]);
823 signed_out.tiers = &tiers;
824 let rendered = html(&signed_out);
825 assert!(rendered.contains("Log in to Subscribe"), "{rendered}");
826 assert!(!rendered.contains("/stripe/subscribe/t1"), "{rendered}");
827
828 let mut signed_in = store(&project, &[]);
829 signed_in.tiers = &tiers;
830 signed_in.signed_in = true;
831 let rendered = html(&signed_in);
832 assert!(rendered.contains("/stripe/subscribe/t1"), "{rendered}");
833 assert!(rendered.contains("promo_code"), "{rendered}");
834 assert!(!rendered.contains("_csrf"), "{rendered}");
835
836 let mut subscribed = store(&project, &[]);
837 subscribed.tiers = &tiers;
838 subscribed.signed_in = true;
839 subscribed.has_subscription = true;
840 let rendered = html(&subscribed);
841 assert!(rendered.contains("Subscribed"), "{rendered}");
842 assert!(!rendered.contains("/stripe/subscribe/t1"), "{rendered}");
843 }
844
845 /// The report dialogue is one file with two callers, and it is only handed
846 /// over to a reader who could file one.
847 #[test]
848 fn the_report_dialogue_is_paid_for_only_when_there_is_a_session() {
849 let project = project();
850 let rendered = html(&store(&project, &[]));
851 assert!(!rendered.contains("report-modal"), "{rendered}");
852 assert!(rendered.contains(">Report<"), "{rendered}");
853
854 let mut signed_in = store(&project, &[]);
855 signed_in.signed_in = true;
856 let rendered = html(&signed_in);
857 assert!(rendered.contains("id=\"report-modal\""), "{rendered}");
858 assert!(rendered.contains("/api/reports"), "{rendered}");
859 }
860
861 /// The offers that depend on what the project has: a blog, repositories, a
862 /// forum. Each was a conditional in the template and each still is.
863 #[test]
864 fn the_page_offers_only_what_the_project_actually_has() {
865 let project = project();
866 let bare = html(&store(&project, &[]));
867 assert!(!bare.contains("/p/blue-hour/blog"), "{bare}");
868 assert!(!bare.contains(">Community<"), "{bare}");
869
870 let repos = [("engine".to_owned(), "/git/maxj/engine".to_owned())];
871 let mut full = store(&project, &[]);
872 full.has_blog_posts = true;
873 full.git_repos = &repos;
874 full.community_url = Some("https://forum.example.com/p/blue-hour");
875 let rendered = html(&full);
876 assert!(rendered.contains("/p/blue-hour/blog"), "{rendered}");
877 assert!(rendered.contains("Git (engine)"), "{rendered}");
878 // The forum is another deployment and a reader is expected to come
879 // back, which is what a tab means.
880 assert!(rendered.contains("target=\"_blank\""), "{rendered}");
881 }
882
883 /// A project with nothing published says so rather than drawing an empty
884 /// list, which is what `ui::empty_state` was for.
885 #[test]
886 fn a_project_with_no_items_says_so() {
887 let project = project();
888 assert!(html(&store(&project, &[])).contains("Nothing published here yet."));
889 }
890
891 /// `Act::copies` is what `data-copy-link` meant, and the value is absolute.
892 #[test]
893 fn the_share_control_copies_the_whole_address() {
894 let project = project();
895 assert!(
896 html(&store(&project, &[]))
897 .contains("data-copies=\"https://makenot.work/p/blue-hour\""),
898 );
899 }
900
901 /// `736f45a5`: none of the four spellings.
902 #[test]
903 fn the_page_spells_no_spinner() {
904 let project = project();
905 let items = [item("i1")];
906 let rendered = html(&store(&project, &items));
907 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
908 assert!(!rendered.contains(spelling), "{spelling} survives");
909 }
910 }
911 }
912