//! The public project page, described.
//!
//! `/p/{slug}`: the cover, who made it, what is in it, and every way to follow,
//! read, subscribe to or support it. It replaces `templates/pages/project.html`
//! and `ProjectTemplate`, and it is the last of the three pages that wrote
//! ` ` by hand.
//!
//! # Why the route stays an axum handler
//!
//! [`super::project_blog`]'s reason, at the page it is largest for. The handler
//! resolves a slug, runs the project-level paywall gate, loads items, tiers,
//! git repos, sections, gallery frames and follow state, and records a page
//! view unless the caller is a crawler. `super::Viewer` carries no session and
//! none of that wants a described route. What is described is the document.
//!
//! # Four behaviours the vocabulary took over
//!
//! - **The section tabs.** `.section-tab` buttons over `.section-panel` divs,
//! switched by `static/page-project.js`. A [`RegionKind::TabGroup`] whose
//! children carry a [`Slot::label`] is that, and `quasi-webview` writes the
//! strip and the program that moves it. The script is gone.
//! - **The gallery.** An Askama macro over the shipped widget. The described
//! half of the same widget is [`super::widgets::carousel::region`], so the
//! gallery here and the three templates still calling the macro cannot be two
//! different carousels. Its frames are ordinary pictures, so a host that has
//! never heard of a carousel still draws every one of them.
//! - **The tip offer's disclosure**, which is [`super::tip`]'s.
//! - **The share link**, which is [`quasi_router::Act::copies`].
//!
//! # Three that did not survive, and why
//!
//! **The grid/list toggle.** Two `.view-btn`s over two copies of the same
//! items, with the choice kept in `localStorage` by
//! `static/page-project-2.js`. The list copy carried strictly less than the
//! grid copy -- a title, a type and a price against a cover, tags, a
//! description, a date and a sales count -- so what the toggle offered was one
//! full view and one lossy one. There is no member for how densely a list is
//! drawn and there should not be: that is the renderer's, which is the whole
//! premise. One described list, drawn once.
//!
//! **`.follow-btn.is-selected`**, for [`super::follow`]'s reason.
//!
//! **The promo code's ``.** One optional input behind a summary, per
//! tier. Said as a field with the placeholder the template wrote, because a
//! [`Reveal`](quasi_router::Reveal) names its control by name and every tier
//! card on the page would have named the same one.
//!
//! # What is still markup, and who owes it
//!
//! **The section bodies.** [`Node::rich`] carries markdown and quasi renders
//! it, and a project section's markdown has been through
//! [`crate::markdown::render_creator_markdown`] first: media paths rewritten
//! against the CDN under the creator's own id, off-platform media hosts
//! stripped, and ` ` of a video file turned into a ``. None of that
//! is derivable from the source, so handing quasi the source would silently
//! drop three transforms a creator's page depends on. The panels are
//! [`RegionKind::Handover`]s and this module fills them; the *strip* over them
//! is described, which is the half that was a script.
//!
//! **The report modal.** A dialogue a control opens, and the vocabulary's
//! [`RegionKind::Modal`] is a modal that arrives with the screen. The markup is
//! still `templates/partials/report_modal.html`, rendered through
//! [`crate::templates::ReportModalTemplate`], so `pages/item.html` and this
//! page cannot drift: one file, two callers.
use makeover_layout as layout;
use quasi_declare::declare;
use quasi_router::{Action, Document, Feed, FeedKind, RegionKind, Tag};
use quasi_webview::Webview;
use crate::templates::CarouselFrame;
use crate::types::{Item, Project, ProjectSection, SubscriptionTier};
/// The page's own region, and what the skip link points at.
pub const PAGE_REGION: &str = "project-store";
/// The tab group the creator's own sections sit in.
const SECTIONS_REGION: &str = "project-sections";
/// The carousel the gallery is.
const GALLERY_REGION: &str = "project-gallery";
/// The place the report dialogue is handed over in.
///
/// The id `partials/report_modal.html` writes and `actions-partials.js` looks
/// for, so the control that opens it keeps working without either side being
/// told about the other.
const REPORT_REGION: &str = "report-modal";
/// How wide it runs. The template wrote this on the body.
const MEASURE: layout::Measure = layout::Measure::Wide;
/// Where a project's feed answers.
#[must_use]
pub fn feed_path(slug: &str) -> String {
format!("/p/{slug}/rss")
}
/// One repository linked to this project.
///
/// Named members rather than a tuple, for `policy`'s reason: a description
/// names what it draws, and `.1` is not a name.
pub struct LinkedRepo {
/// What the repository is called.
pub name: String,
/// Where it is on this site.
pub url: String,
}
/// Everything the screen is about.
///
/// A struct rather than twenty arguments, for [`super::user::Profile`]'s
/// reason.
pub struct Store<'a> {
/// The project.
pub project: &'a Project,
/// Its id, which is what the follow route takes.
pub project_id: &'a str,
/// Who made it.
pub creator_username: &'a str,
/// Their id, which is what the tip route takes.
pub creator_id: &'a str,
/// The site's own base address, for the absolute URLs a crawler and a share
/// sheet read.
pub host_url: &'a str,
/// What is published in it.
pub items: &'a [Item],
/// The creator's own tabbed sections.
pub sections: &'a [ProjectSection],
/// The gallery, beside the cover rather than instead of it.
pub gallery: &'a [CarouselFrame],
/// The tiers a reader may subscribe to.
pub tiers: &'a [SubscriptionTier],
/// Linked repositories.
pub git_repos: &'a [LinkedRepo],
/// The paired forum, when one is provisioned.
pub community_url: Option<&'a str>,
/// How many people follow it.
pub follower_count: i64,
/// Whether the viewer does.
pub is_following: bool,
/// Whether the viewer already subscribes.
pub has_subscription: bool,
/// Whether the viewer owns it.
pub is_owner: bool,
/// Whether there is a session at all.
pub signed_in: bool,
/// Whether the project has anything on its blog.
pub has_blog_posts: bool,
/// Whether the creator takes tips.
pub tips_enabled: bool,
}
impl Store<'_> {
/// The address this page answers at.
fn canonical(&self) -> String {
format!("{}/p/{}", self.host_url, self.project.slug)
}
/// The picture a link preview shows: the cover, or the site's card.
fn image(&self) -> String {
self.project
.cover_image_url
.clone()
.filter(|url| !url.trim().is_empty())
.unwrap_or_else(|| format!("{}/static/images/og-card.png", self.host_url))
}
/// Whether there is cover art to draw.
///
/// A predicate rather than an `Option` the description reaches into, which
/// is the call `super::embeds::ItemView::has_cover` records.
fn has_cover(&self) -> bool {
!self.cover().is_empty()
}
/// The cover's address, or nothing. R9: the picture is built either way.
fn cover(&self) -> &str {
self.project
.cover_image_url
.as_deref()
.map(str::trim)
.unwrap_or_default()
}
/// What a tip on this page is about.
///
/// A method rather than the literal the caller used to write inline: a
/// `Type { .. }` aggregate is the form's hard limit.
fn tip_offer(&self) -> super::tip::Offer<'_> {
super::tip::Offer {
creator_id: self.creator_id,
project_id: Some(self.project_id),
signed_in: self.signed_in,
}
}
/// Whether this reader is offered the subscribe form.
fn offers_subscribe(&self) -> bool {
!self.has_subscription && self.signed_in
}
/// Whether they are offered the way to get a session instead.
fn must_sign_in(&self) -> bool {
!self.has_subscription && !self.signed_in
}
/// The structured data the template opened a `CollectionPage` block with.
///
/// Escaped for JSON rather than for HTML, which is what it is: a JSON
/// document inside a `")
}
}
/// Whether an item has cover art to draw.
///
/// A free function rather than a method, because `Item` is `crate::types`' and
/// this is a fact this screen wants rather than a fact about the item.
fn item_has_cover(item: &Item) -> bool {
item.cover().is_some()
}
/// Its address, or nothing. R9: the picture is built either way.
fn item_cover(item: &Item) -> &str {
item.cover().unwrap_or_default()
}
/// The trailing line: what kind of thing it is, how many it holds, and when it
/// came out.
fn item_meta(item: &Item) -> String {
if item.bundle_item_count > 0 {
format!(
"{} ({} items) - {}",
item.item_type, item.bundle_item_count, item.release_date
)
} else {
format!("{} - {}", item.item_type, item.release_date)
}
}
/// Where opening the row goes: the item, or the way to buy it.
fn item_destination(item: &Item) -> String {
if item.can_access {
format!("/i/{}", item.id)
} else {
format!("/purchase/{}", item.id)
}
}
/// The reader already has it.
fn offers_library(item: &Item) -> bool {
item.can_access
}
/// It costs nothing and they can take it.
fn offers_free(item: &Item) -> bool {
!item.can_access && item.is_free
}
/// They name their own price.
fn offers_pwyw(item: &Item) -> bool {
!item.can_access && !item.is_free && item.pwyw_enabled
}
/// They pay the listed price. The fourth of four, and the four are exhaustive
/// and disjoint by construction.
fn offers_purchase(item: &Item) -> bool {
!item.can_access && !item.is_free && !item.pwyw_enabled
}
declare! {
/// One item, as a row.
///
/// Everything the card carried, in the roles the vocabulary has for them:
/// the cover and the title are what the row is called, the type and the
/// date are its meta, the description is its secondary line, the tags are
/// its tokens, and the way to get it is what it offers.
///
/// The one way in, per state. The template drew four and so does this, as
/// four guarded controls rather than a dispatch: a dispatch arm is one
/// emission and these are one each, but the guards say which four states
/// they answer to and a reader can check they are exhaustive.
shape item_row(item: &Item) -> Row;
row "" {
beside Primary picture item_cover(item) item.title.clone()
when item_has_cover(item) {
lazy;
}
beside Primary text item.title.clone();
meta item_meta(item);
secondary item.description.clone();
relaxed;
for tag in item.tags.iter() {
token Tag::chip(
tag.name.clone(),
Action::get("/discover").carrying("tag", tag.slug.clone()).navigating()
);
}
beside Meta text item.price.clone();
beside Meta text "{item.sales_count} sales";
activate to get item_destination(item) navigating;
act "View in library" to get "/l/{item.id}" navigating when offers_library(item);
act "Add to Library" to post "/api/library/add/{item.id}" invalidating
when offers_free(item);
act "Pay What You Want" to get item_destination(item) navigating
when offers_pwyw(item);
act "Buy Once" to get item_destination(item) navigating when offers_purchase(item);
}
}
declare! {
/// One membership tier, as a group.
shape tier_group(store: &Store<'_>, tier: &SubscriptionTier) -> Node;
region "tier-{tier.id}" as Group {
section tier.name.clone();
text tier.price.clone();
text tier.description.clone() unless tier.description.is_empty();
badge "Subscribed" when store.has_subscription;
// The hidden `_csrf` goes the way the tip form's did: this arrives as
// an htmx post and `frontend/src/core/htmx-glue.ts` attaches the token
// from the document's meta. `create_subscription_checkout` ends at
// Stripe, so it answers `HX-Redirect` to an htmx caller.
form post "/stripe/subscribe/{tier.id}" when store.offers_subscribe() {
submit "Subscribe";
field Text "promo_code" "Promo code (optional)" {
placeholder "e.g. TRIAL14";
}
}
act "Log in to Subscribe" to get "/login" navigating when store.must_sign_in();
}
}
declare! {
/// The whole document.
#[must_use]
pub shape screen(store: &Store<'_>, theme_css: &str) -> Screen;
let feed = feed_path(&store.project.slug);
screen single "{store.project.title} - {store.creator_username}" {
measured MEASURE;
documented Document::default()
.classed(crate::shell::body_class(MEASURE, &["project-page"]))
// Tier 0, as `super::user` carries the creator's.
.styled(theme_css.to_owned());
summarised store.project.description.clone();
illustrated store.image();
canonical_at store.canonical();
about quasi_router::SocialKind::Product;
syndicating Feed::new(FeedKind::Rss, "{store.project.title} - RSS Feed", feed);
region PAGE_REGION as Pane {
picture store.cover() store.project.title.clone() when store.has_cover();
page store.project.title.clone();
link store.creator_username to get "/u/{store.creator_username}" navigating;
text "{store.project.item_count} items";
text store.project.description.clone();
act "Edit Project" to get "/dashboard/project/{store.project.slug}" navigating
when store.is_owner;
include super::follow::control(
"project",
store.project_id,
store.is_following,
store.follower_count
) when store.signed_in;
// An `-> Option<_>` shape is placed with a loop, because an
// `Option` is an iterator of at most one.
for counted in super::follow::count_only(store.follower_count).into_iter() {
include counted unless store.signed_in;
}
act "RSS Feed" to get feed.clone() navigating;
act "Blog" to get "/p/{store.project.slug}/blog" navigating
when store.has_blog_posts;
for repo in store.git_repos.iter() {
act "Git ({repo.name})" to get repo.url.clone() navigating;
}
// The forum is a separate deployment on its own host, and a reader
// is expected to come back, which is what `external` says.
for url in store.community_url.into_iter() {
act "Community" to external url;
}
include super::tip::control(&store.tip_offer()) when store.tips_enabled;
// The shipped widget rather than a fourth hand-rolled carousel:
// `super::widgets::carousel` is the one place a template frame
// becomes a described one, and the Askama macro the item page still
// calls goes through it too.
include super::widgets::carousel::region(GALLERY_REGION, store.gallery)
unless store.gallery.is_empty();
region SECTIONS_REGION as TabGroup unless store.sections.is_empty() {
showing_one 0;
for section in store.sections.iter() {
region panel_region(§ion.slug) as RegionKind::handover("a project section") {
label section.title.clone();
}
}
}
section "Available Items";
given store.items.is_empty() {
true -> empty "Nothing published here yet.";
otherwise -> list {
for item in store.items.iter() {
include item_row(item);
}
}
}
section "Membership" unless store.tiers.is_empty();
for tier in store.tiers.iter() {
include tier_group(store, tier);
}
link "Powered by Makenot.work" to get "/" navigating;
text "Fair distribution for creatives of all kinds";
act "Copy link" to local {
copying store.canonical();
}
link "Policy" to get "/policy" navigating;
// Reporting is a write, so it needs a session. Signed out, the
// offer is the way to get one, which is what the template drew.
region REPORT_REGION as RegionKind::handover("the report dialogue")
when store.signed_in {}
link "Report" to get "/login" navigating unless store.signed_in;
}
}
}
/// The id of the region one section's body is handed over in.
///
/// `section-` because that is what `style.css` matched and what the
/// script's `history.replaceState` wrote into the fragment, so an old link into
/// a section still lands on it.
fn panel_region(slug: &str) -> String {
format!("section-{slug}")
}
/// The document this screen is drawn in, with every handover paid.
#[must_use]
pub fn renderer(
viewer: Option<&crate::auth::SessionUser>,
csrf: Option<&str>,
store: &Store<'_>,
) -> Webview {
let csrf = csrf.unwrap_or_default();
let mut webview = Webview::new().with_shell(
crate::shell::described()
.sending("X-CSRF-Token", csrf)
.with_body_last(crate::shell::body_last())
.with_body_first(format!(
"{}{}",
crate::shell::skip_link(PAGE_REGION),
crate::shell::site_header(viewer)
))
.with_head(format!(
" {}",
crate::helpers::escape_html(csrf),
store.structured_data()
)),
);
for section in store.sections {
webview = webview.with_fill(panel_region(§ion.slug), section.body_html.clone());
}
if store.signed_in {
webview = webview.with_fill(REPORT_REGION, report_markup(store.project_id));
}
webview
}
/// The report dialogue, from the file `pages/item.html` still includes.
///
/// One copy of the markup, two callers. A failure to render is an empty fill
/// rather than a failure to draw the page: the dialogue is one control on a
/// storefront, and losing the storefront over it is the wrong trade.
fn report_markup(project_id: &str) -> String {
crate::templates::ReportModalTemplate {
report_target_type: "project",
report_target_id: project_id.to_owned(),
report_has_labels: true,
}
.render_string()
.unwrap_or_default()
}
/// Render it.
#[must_use]
pub fn document(
viewer: Option<&crate::auth::SessionUser>,
csrf: Option<&str>,
store: &Store<'_>,
theme_css: &str,
) -> String {
use quasi_axum::Serves as _;
let screen = screen(store, theme_css);
renderer(viewer, csrf, store).screen(&screen)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::TagView;
fn project() -> Project {
Project {
id: "p1".to_owned(),
slug: "blue-hour".to_owned(),
title: "Blue Hour".to_owned(),
description: "Field recordings".to_owned(),
item_count: 2,
project_type: "Music".to_owned(),
cover_image_url: None,
}
}
fn item(id: &str) -> Item {
Item {
id: id.to_owned(),
title: format!("Track {id}"),
price: "$5".to_owned(),
price_cents: 500,
item_type: "Audio".to_owned(),
description: "A recording".to_owned(),
thumbnail: "Audio".to_owned(),
release_date: "2026-08-01".to_owned(),
sales_count: 7,
tags: vec![TagView {
id: "t1".to_owned(),
name: "Ambient".to_owned(),
slug: "ambient".to_owned(),
is_primary: true,
}],
content: crate::types::ItemContent::Text {
body: None,
body_html: None,
reading_time: None,
reading_time_minutes: None,
word_count: None,
},
cover_image_url: None,
is_free: false,
can_access: false,
enable_license_keys: false,
default_max_activations: None,
pwyw_enabled: false,
pwyw_min_cents: None,
publish_at: None,
is_public: true,
listed: true,
bundle_item_count: 0,
license_preset: None,
custom_license_text: None,
ai_tier: crate::db::AiTier::Handmade,
ai_disclosure: None,
}
}
fn store<'a>(project: &'a Project, items: &'a [Item]) -> Store<'a> {
Store {
project,
project_id: "p1",
creator_username: "maxj",
creator_id: "u1",
host_url: "https://makenot.work",
items,
sections: &[],
gallery: &[],
tiers: &[],
git_repos: &[],
community_url: None,
follower_count: 0,
is_following: false,
has_subscription: false,
is_owner: false,
signed_in: false,
has_blog_posts: false,
tips_enabled: false,
}
}
fn html(store: &Store<'_>) -> String {
document(None, Some("t"), store, ":root{--x:1}")
}
/// The last of the three hand-written tags, said by the screen, spelled by
/// the vocabulary, and emitted once.
#[test]
fn the_feed_is_declared_once_and_spelled_by_the_vocabulary() {
let project = project();
let screen = screen(&store(&project, &[]), "");
let feed = screen.discovery.feed.as_ref().expect("declared");
assert_eq!(feed.href, "/p/blue-hour/rss");
assert_eq!(feed.title, "Blue Hour - RSS Feed");
let rendered = html(&store(&project, &[]));
assert!(
rendered.contains(
" "
),
"{rendered}"
);
assert_eq!(
rendered.matches("rel=\"alternate\"").count(),
1,
"{rendered}"
);
assert!(rendered.contains(">RSS Feed<"), "{rendered}");
}
/// The creator's Tier 0 sheet lands after every stylesheet the shell
/// carries, which is what the unlayered `