//! 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_router::screen::{Field, Image, Row};
use quasi_router::{
Act, Action, Document, Feed, FeedKind, Node, RegionKind, Screen as Described, Slot, 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")
}
/// 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, as name and address.
pub git_repos: &'a [(String, String)],
/// 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))
}
/// 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 `")
}
}
/// 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.
fn item_row(item: &Item) -> Row {
let destination = if item.can_access {
format!("/i/{}", item.id)
} else {
format!("/purchase/{}", item.id)
};
let mut row = Row::default();
if let Some(cover) = item.cover() {
row = row.part(
layout::RowPart::Primary,
Node::Image(Image::new(cover, item.title.clone()).lazy()),
);
}
row = row
.part(layout::RowPart::Primary, Node::text(item.title.clone()))
.meta(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)
})
.secondary(item.description.clone())
.relaxed();
for tag in &item.tags {
row = row.token(Tag::chip(
tag.name.clone(),
Action::get("/discover")
.carrying("tag", tag.slug.clone())
.navigating(),
));
}
row = row
.part(layout::RowPart::Meta, Node::text(item.price.clone()))
.part(
layout::RowPart::Meta,
Node::text(format!("{} sales", item.sales_count)),
)
.activate(Action::get(&destination).navigating());
// The one way in, per state. The template drew four and so does this.
if item.can_access {
row.act(Act::new(
"View in library",
Action::get(format!("/l/{}", item.id)).navigating(),
))
} else if item.is_free {
row.act(Act::new(
"Add to Library",
Action::post(format!("/api/library/add/{}", item.id)).invalidating(),
))
} else if item.pwyw_enabled {
row.act(Act::new(
"Pay What You Want",
Action::get(&destination).navigating(),
))
} else {
row.act(Act::new("Buy Once", Action::get(&destination).navigating()))
}
}
/// One membership tier, as a group.
fn tier_group(store: &Store<'_>, tier: &SubscriptionTier) -> Node {
let mut group = Slot::new(format!("tier-{}", tier.id), RegionKind::Group)
.with(Node::section(tier.name.clone()))
.with(Node::text(tier.price.clone()));
if !tier.description.is_empty() {
group = group.with(Node::text(tier.description.clone()));
}
group = if store.has_subscription {
group.with(Node::Token(Tag::badge("Subscribed")))
} else if store.signed_in {
let mut promo = Field::new(
layout::FieldKind::Text,
"promo_code",
"Promo code (optional)",
);
promo.placeholder = Some("e.g. TRIAL14".to_owned());
group.with(Node::Form {
// 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.
action: Action::post(format!("/stripe/subscribe/{}", tier.id)),
submit: "Subscribe".to_owned(),
fields: vec![promo],
})
} else {
group.with(Node::act(
"Log in to Subscribe",
Action::get("/login").navigating(),
))
};
Node::Region(group)
}
/// The whole document.
#[must_use]
pub fn screen(store: &Store<'_>, theme_css: &str) -> Described {
let project = store.project;
let feed = feed_path(&project.slug);
let mut page = Slot::new(PAGE_REGION, RegionKind::Pane);
if let Some(cover) = project
.cover_image_url
.as_deref()
.map(str::trim)
.filter(|url| !url.is_empty())
{
page = page.with(Node::Image(Image::new(cover, project.title.clone())));
}
page = page
.with(Node::page(project.title.clone()))
.with(Node::Link {
text: store.creator_username.to_owned(),
action: Action::get(format!("/u/{}", store.creator_username)).navigating(),
})
.with(Node::text(format!("{} items", project.item_count)))
.with(Node::text(project.description.clone()));
if store.is_owner {
page = page.with(Node::act(
"Edit Project",
Action::get(format!("/dashboard/project/{}", project.slug)).navigating(),
));
}
if store.signed_in {
page = page.with(super::follow::control(
"project",
store.project_id,
store.is_following,
store.follower_count,
));
} else if let Some(count) = super::follow::count_only(store.follower_count) {
page = page.with(count);
}
page = page.with(Node::act(
"RSS Feed",
Action::get(feed.clone()).navigating(),
));
if store.has_blog_posts {
page = page.with(Node::act(
"Blog",
Action::get(format!("/p/{}/blog", project.slug)).navigating(),
));
}
for (name, url) in store.git_repos {
page = page.with(Node::act(
format!("Git ({name})"),
Action::get(url.clone()).navigating(),
));
}
if let Some(url) = store.community_url {
// The forum is a separate deployment on its own host, and a reader is
// expected to come back, which is what `External` says.
page = page.with(Node::act("Community", Action::external(url.to_owned())));
}
if store.tips_enabled {
page = page.with(super::tip::control(&super::tip::Offer {
creator_id: store.creator_id,
project_id: Some(store.project_id),
signed_in: store.signed_in,
}));
}
if !store.gallery.is_empty() {
// 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.
page = page.with(super::widgets::carousel::region(
GALLERY_REGION,
store.gallery,
));
}
if !store.sections.is_empty() {
let mut tabs = Slot::new(SECTIONS_REGION, RegionKind::TabGroup).showing_one(0);
for section in store.sections {
tabs = tabs.with(Node::Region(
Slot::handover(panel_region(§ion.slug), "a project section")
.label(section.title.clone()),
));
}
page = page.with(Node::Region(tabs));
}
page = page
.with(Node::section("Available Items"))
.with(if store.items.is_empty() {
Node::empty("Nothing published here yet.")
} else {
Node::list(store.items.iter().map(item_row))
});
if !store.tiers.is_empty() {
page = page.with(Node::section("Membership"));
for tier in store.tiers {
page = page.with(tier_group(store, tier));
}
}
page = page
.with(Node::Link {
text: "Powered by Makenot.work".to_owned(),
action: Action::get("/").navigating(),
})
.with(Node::text(
"Fair distribution for creatives of all kinds".to_owned(),
))
.with(Node::Act(
Act::new("Copy link", Action::local()).copying(store.canonical()),
))
.with(Node::Link {
text: "Policy".to_owned(),
action: Action::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.
if store.signed_in {
page = page.with(Node::Region(Slot::handover(
REPORT_REGION,
"the report dialogue",
)));
} else {
page = page.with(Node::Link {
text: "Report".to_owned(),
action: Action::get("/login").navigating(),
});
}
Described::single(format!("{} - {}", 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(project.description.clone())
.illustrated(store.image())
.canonical_at(store.canonical())
.about(quasi_router::SocialKind::Product)
.syndicating(Feed::new(
FeedKind::Rss,
format!("{} - RSS Feed", project.title),
feed,
))
.with(page)
}
/// 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 `