//! The two feed surfaces, described. //! //! `830b1661`, and the first described paging anywhere on this server: //! `grep -rn 'Rest' src/quasi/` returned one hit before this module and it was //! the string "Restore". //! //! # Why the two go together //! //! `partials/tabs/library_feed.html` (the panel behind `/library/tabs/feed`) //! and `pages/feed.html` (the page at `/feed`) drew the same thing: the same //! `Vec` in the same five columns, from the same query, with the //! same numbered strip under it. Two handlers, two templates, one table. //! Converting one and leaving the other would leave that table described in one //! place and spelled in markup in the other, which is the divergence a //! conversion exists to end. //! //! So the table is described once, here, and both surfaces call it. What //! differs between them is one thing and it is stated as one thing: where a //! page control goes. The panel's paging swaps the panel //! ([`Action::replacing`]); the page's paging replaces the document //! ([`Action::navigating`]). //! //! # The window stays server-side //! //! [`Rest::jumps`] is explicit that windowing is the description's call rather //! than the renderer's, and `super::super::routes::pages::public::pagination::build_pagination_range` //! already windows to five around the reader. That function is untouched: what //! arrives here is the pages being offered, in reading order, and each is given //! its own address. A renderer that built page 5's address out of `forward` and //! `back` would be reconstructing the `?page=` grammar this retires. //! //! # What the row is //! //! A whole row is the link, which is what the markup said with `` wrapping five spans. [`Cells::activate`] is that, //! and it carries [`Action::navigating`] because an item page is a page: a bare //! `Action::get` emits an `href` *and* an `hx-get` with no target, and htmx //! puts the whole document inside the row that was pressed. That is //! quasicoherent `00ee7af5`, met here for the second time on this server. //! //! # The two-line name cell //! //! Name over creator, one cell, which the template drew as a nested `
` //! with two spans. [`Cell::part`] is the general form for exactly this and //! needs no member: the cell is an inline run holding two leaves, the first of //! which [`Cell::activate`] turns into the row's link text. //! //! # What is restated, and it is one line //! //! "Showing 1-20 of 400 items" is prose above the table, and every number in it //! is also in the [`Rest`] under the table. That is a duplication and it is //! deliberate: the webview renderer draws the numbered strip *instead of* its //! position readout (`rest_strip_html`, "a control arguing with itself"), so a //! reader given a strip is told which page they are on and never how many items //! there are. Dropping the line would be a visible loss to buy tidiness in a //! layer the reader cannot see. A renderer that printed the total beside a //! strip would let this go, and that is a `quasi-webview` question rather than //! this server's. //! //! # The page owns its document //! //! `b5cbb646`. `pages/feed.html` is gone and so is `landing`'s Askama route: //! [`screen`] describes the whole page and [`renderer`] draws the document it //! sits in. The site header is not described and is not going to be: it is one //! element in the assembly layer ([`crate::shell::site_header`]), handed to the //! shell as `body_first` here and included by 64 templates there. The ruling //! and its evidence are in wiki `mnw-server-conversion-plan`, dated 2026-08-31. //! //! One behaviour changed with the route. `ValidatedQuery` refused //! `?page=abc` with a 400; [`screen`] parses what it can and falls back to page //! one, which is what a reader who mangled a URL wants and what every other //! described screen already does with its carried values. //! //! The six other `page=` sites are Newer/Older or Previous/Next only and want //! no `jumps`: `pages/git/{commits,explore,issues,file_log,notes}.html` and //! `partials/admin_user_entries.html`. Named here so a later pass does not //! "fix" them into strips they never had. //! //! [`Action::replacing`]: quasi_router::Action::replacing //! [`Action::navigating`]: quasi_router::Action::navigating //! [`Cells::activate`]: quasi_router::screen::Cells::activate //! [`Cell::part`]: quasi_router::screen::Cell::part use makeover_layout as layout; use quasi_router::screen::{Act, Cell, Cells, Column, Rest, Tag}; use quasi_router::{ Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, }; use quasi_webview::Webview; use crate::constants; use crate::types::DiscoverItem; /// The region the library panel's answer lands in. /// /// The id `super::library_tabs` draws its Feed frame from, so a page control /// swapping this swaps the panel and nothing around it. pub const LIBRARY_REGION: &str = "library-feed"; /// The region the public page's body sits in. /// /// Public so the pressed-screen table and the skip link name it rather than /// transcribe it. pub const PAGE_REGION: &str = "feed"; /// This screen's name, the marker a tab strip reads. pub const SCREEN: &str = "feed"; /// The address this screen answers, and the one the Askama route gave up. pub const PATH: &str = "/feed"; /// How wide the page runs. `pages/feed.html` said this as /// `class="{{ shell::measure(Wide) }}"`; it is a described property now. const MEASURE: layout::Measure = layout::Measure::Wide; /// The address the library panel is read from. const LIBRARY_ROUTE: &str = "/library/tabs/feed"; /// One page of a feed, as both handlers have already computed it. /// /// Every field here is what the two templates were handed, under the names they /// were handed them under. What owns the values is [`Loaded`], and the /// arithmetic that produces them is [`load`]: both handlers used to carry a /// verbatim copy of it, which is one clamp, one `i64` widening and two /// saturating labels duplicated three lines apart. pub struct Page<'a> { /// The rows, in the order they read. pub items: &'a [DiscoverItem], /// How many there are altogether. pub total_items: u32, /// Which page this is, counting from one. pub current_page: u32, /// How many pages there are. pub total_pages: u32, /// The pages the strip offers, already windowed by the handler. pub pagination_range: &'a [u32], /// The first row's position in the whole set, counting from one. pub showing_start: u32, /// The last row's position in the whole set. pub showing_end: u32, } /// The library's Feed panel, in its region, for the tab route to answer with. #[must_use] pub fn library_fragment(page: &Page<'_>) -> String { use quasi_axum::Serves as _; let mut slot = Slot::new(LIBRARY_REGION, RegionKind::Pane); for node in body(page, Surface::Panel) { slot = slot.with(node); } Webview::new().fragment(&Node::Region(slot)) } /// One page of a reader's feed, loaded. Owns what [`Page`] borrows. pub struct Loaded { items: Vec, total_items: u32, current_page: u32, total_pages: u32, pagination_range: Vec, showing_start: u32, showing_end: u32, } impl Loaded { /// What was loaded, as the description reads it. #[must_use] pub fn page(&self) -> Page<'_> { Page { items: &self.items, total_items: self.total_items, current_page: self.current_page, total_pages: self.total_pages, pagination_range: &self.pagination_range, showing_start: self.showing_start, showing_end: self.showing_end, } } } /// Read one page of a reader's feed. /// /// The clamp, the `i64` widening before the multiply and the saturating /// "showing" labels are three overflow fixes, moved here verbatim rather than /// re-derived. They lived in `routes::pages::public::feed` and in /// `landing::library_tab_feed` as byte-identical copies; there is one now, so /// the library panel and the page cannot page differently. /// /// Async, so the panel awaits it and the described screen reaches it through /// [`super::Viewer::block_on`]. pub async fn load( db: &sqlx::PgPool, user: crate::db::UserId, page: Option, ) -> crate::error::Result { // Clamp the upper bound too (matches admin/git pagination); the i64 // widening below already prevents the overflow panic, but an unbounded page // is a pointless huge offset (Run #2 UX MINOR). let page = page.unwrap_or(1).clamp(1, 1_000_000_000); // Widen to i64 BEFORE multiplying, `(page - 1) * FEED_PAGE_SIZE` in u32 // overflows for a large `?page=` (garbage offset in release, panic in debug). let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64; let total_items = crate::db::follows::count_followed_feed_items(db, user).await? as u32; let total_pages = (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1); let db_items = crate::db::follows::get_followed_feed_items( db, user, constants::FEED_PAGE_SIZE as i64, offset, ) .await?; let items: Vec = db_items.into_iter().map(DiscoverItem::from).collect(); // Compute the "showing X-Y" labels in i64 (saturating) to avoid the u32 // overflow `offset as u32 + FEED_PAGE_SIZE` would hit for a large `?page=`. let showing_start = if total_items == 0 { 0 } else { offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32 }; let showing_end = offset .saturating_add(constants::FEED_PAGE_SIZE as i64) .min(total_items as i64) .clamp(0, u32::MAX as i64) as u32; Ok(Loaded { items, total_items, current_page: page, total_pages, pagination_range: crate::routes::pages::public::pagination::build_pagination_range( page, total_pages, ), showing_start, showing_end, }) } /// The public feed page, described. pub fn screen(viewer: &super::Viewer, request: Request) -> Result { // Moved out of the request rather than borrowed: the signature is quasi's, // so the request arrives owned and nothing else here reads it. let carried = request.carried; let asked = carried .get("page") .and_then(|value| value.trim().parse::().ok()); let loaded = viewer .block_on(load(&viewer.app.db, viewer.user.id, asked)) .map_err(|_| RouteError::internal("your feed could not be read"))?; Ok(page_screen(&loaded.page()).into()) } /// The whole document: the title, the measure, the body. fn page_screen(page: &Page<'_>) -> Described { let mut pane = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page("Your Feed")); for node in body(page, Surface::Page) { pane = pane.with(node); } Described::single("Feed - Makenotwork") .measured(MEASURE) // `padded-page feed-page`, which is what `pages/feed.html:4` rendered. // Composed rather than written out: `Document::classed` replaces, so a // screen naming only its own token would drop its measure (`2790e5c4`). .documented(Document::default().classed(crate::shell::body_class(MEASURE, &["feed-page"]))) .summarised("Items from the users, projects and tags you follow.") .with(pane) } /// The document this screen is drawn in. /// /// The head, the tail and the token meta come off /// [`super::Viewer::document_shell`]. What is added here is what every page on /// this site opens with: the skip link, and the site header /// ([`crate::shell::site_header`]) which is markup in the assembly layer rather /// than anything a screen describes. #[must_use] pub fn renderer(viewer: &super::Viewer) -> Webview { Webview::new().with_shell(viewer.document_shell().with_body_first(format!( "{}{}", crate::shell::skip_link(PAGE_REGION), crate::shell::site_header(Some(&viewer.user), Some(&viewer.csrf)), ))) } /// Which of the two surfaces is being drawn. /// /// The only thing that differs between them, so it is one value rather than two /// copies of the body. See the module header. #[derive(Clone, Copy)] enum Surface { /// The library tab panel: a page control swaps the panel. Panel, /// The public page: a page control replaces the document. Page, } impl Surface { /// What going to `page` calls, on this surface. fn address(self, page: u32) -> Action { match self { Self::Panel => Action::get(format!("{LIBRARY_ROUTE}?page={page}")) .awaiting() .replacing(LIBRARY_REGION), Self::Page => Action::get(format!("{PATH}?page={page}")).navigating(), } } } /// The surface's contents, in order. fn body(page: &Page<'_>, surface: Surface) -> Vec { if page.items.is_empty() { return vec![empty()]; } vec![ Node::text(format!( "Showing {}-{} of {} items", page.showing_start, page.showing_end, page.total_items )), table(page, surface), ] } /// Nothing followed yet. /// /// The two templates spelled this differently -- the panel wrote its own three /// paragraphs and a button, the page called `ui::empty_state_with_action` -- and /// said the same thing with the same way out. One spelling now, which is one of /// the things converting both surfaces together buys. fn empty() -> Node { Node::empty("Nothing here yet. Follow users, projects, or tags to see their items here.") .offering(Act::new( "Browse Discover", Action::get("/discover").navigating(), )) } /// The five columns, and the rows under them. fn table(page: &Page<'_>, surface: Surface) -> Node { Node::Table { columns: vec![ Column::new("Type").width(layout::Width::Content), Column::new("Name") .width(layout::Width::Fill) .priority(layout::Priority::Essential), Column::new("Tag").width(layout::Width::Content), Column::new("Price").width(layout::Width::Content), Column::new("Date").width(layout::Width::Content), ], rows: page.items.iter().map(row).collect(), more: rest(page, surface), } } /// One item. fn row(item: &DiscoverItem) -> Cells { let price = if item.is_free { let mut free = Tag::badge("Free"); free.tone = layout::Tone::Success; Cell::new(String::new()).token(free) } else { Cell::new(item.price.clone()) }; Cells::new([ Cell::new(String::new()).token(Tag::badge(item.item_type.clone())), // The name is the link text and the creator rides under it in the same // cell, which is what `Cell::activate` picks: the first text part. Cell::new(item.name.clone()).part(Node::text(item.creator.clone())), Cell::new(item.primary_tag.clone()), price, Cell::new(item.date.clone()), ]) .activate(Action::get(format!("/i/{}", item.id)).navigating()) } /// What the reader has not been shown, and every way to ask for it. /// /// `None` on a single-page feed, which is what `{% if total_pages > 1 %}` said: /// a set that arrived whole has no rest, and a pager drawn over one would be /// two disabled buttons and the number 1. fn rest(page: &Page<'_>, surface: Surface) -> Option { if page.total_pages <= 1 { return None; } let per = constants::FEED_PAGE_SIZE as usize; let from = (page.current_page as usize).saturating_sub(1) * per; let mut rest = Rest::page(from, per).of(page.total_items as usize); if page.current_page > 1 { rest = rest.back(surface.address(page.current_page - 1)); } if page.current_page < page.total_pages { rest = rest.forward(surface.address(page.current_page + 1)); } for jump in page.pagination_range { rest = rest.jumping(*jump as usize, surface.address(*jump)); } Some(rest) } #[cfg(test)] mod tests { use super::*; fn item(id: &str, free: bool) -> DiscoverItem { DiscoverItem { id: id.to_string(), name: format!("Item {id}"), creator: "acreator".to_string(), project: "aproject".to_string(), item_type: "sample-pack".to_string(), primary_tag: "drums".to_string(), price: "$4.00".to_string(), is_free: free, sales: 0, date: "2026-08-30".to_string(), ai_tier: String::new(), match_label: None, starts_fuzzy_block: false, } } fn page(total_pages: u32) -> (Vec, Vec) { ( vec![item("itm_1", false), item("itm_2", true)], (1..=total_pages).collect::>(), ) } fn library(current: u32, total_pages: u32) -> String { let (items, range) = page(total_pages); library_fragment(&Page { items: &items, total_items: 40, current_page: current, total_pages, pagination_range: &range, showing_start: 1, showing_end: 20, }) } /// The whole document the page answers with, minus the viewer-dependent /// half of the shell (the header and the token meta, which need a session). /// Everything the screen itself describes is here. fn public(current: u32, total_pages: u32) -> String { use quasi_axum::Serves as _; let (items, range) = page(total_pages); Webview::new().screen(&page_screen(&Page { items: &items, total_items: 40, current_page: current, total_pages, pagination_range: &range, showing_start: 1, showing_end: 20, })) } /// Both surfaces draw the same table out of the same description. The /// columns and the rows are the half that must not differ. #[test] fn the_two_surfaces_draw_one_table() { let panel = library(1, 2); let page = public(1, 2); for html in [&panel, &page] { assert!(html.contains("Item itm_1"), "{html}"); assert!(html.contains("acreator"), "{html}"); assert!(html.contains("drums"), "{html}"); assert!(html.contains("$4.00"), "{html}"); // The free badge, which both templates drew as a toned badge. assert!(html.contains(">Free<"), "{html}"); assert!(html.contains(r#"data-tone="success""#), "{html}"); } } /// A row is the link, and it goes to the item rather than into the row. #[test] fn a_row_navigates_to_its_item() { let html = library(1, 2); assert!(html.contains(r#"href="/i/itm_1""#), "{html}"); // `00ee7af5`: a bare `Action::get` would emit an `hx-get` beside the // href and htmx would swap the item page into the row. assert!(!html.contains(r#"hx-get="/i/itm_1""#), "{html}"); } /// The panel's page controls swap the panel; the page's replace the /// document. This is the one thing that differs between the surfaces and it /// is the thing worth asserting twice. #[test] fn each_surface_pages_the_way_it_is_read() { let panel = library(2, 4); assert!( panel.contains(r#"hx-get="/library/tabs/feed?page=3""#), "{panel}" ); assert!(panel.contains(r##"hx-target="#library-feed""##), "{panel}"); let page = public(2, 4); assert!(page.contains(r#"href="/feed?page=3""#), "{page}"); assert!(!page.contains("hx-get=\"/feed"), "{page}"); assert!(!page.contains("hx-target="), "{page}"); } /// The strip is the window the handler chose, each page with its own /// address, and the page the reader is on is text rather than a control. #[test] fn the_strip_offers_the_pages_the_handler_windowed() { let html = library(2, 4); for page in [1u32, 3, 4] { assert!( html.contains(&format!("/library/tabs/feed?page={page}")), "{html}" ); } // Page 2 is where the reader is: marked, and not a control that reloads // the page it is on. assert!(html.contains(r#"aria-current="page""#), "{html}"); assert!( !html.contains(r#"hx-get="/library/tabs/feed?page=2""#), "{html}" ); } /// A first page offers no way back and a last page no way forward, and the /// renderer draws each as disabled rather than absent. #[test] fn the_ends_of_the_set_say_so() { let first = library(1, 4); assert!(first.contains("