//! Templates for public-facing forum pages. use askama::Template; use super::CsrfTokenOption; // View-model structs (lightweight data for templates, not domain models) /// Minimal user info for the site header. pub struct TemplateSessionUser { pub username: String, pub is_platform_admin: bool, } /// Row in the forum directory (home page). pub struct CommunityDirectoryRow { pub slug: String, pub name: String, pub description: Option, pub category_count: u32, pub thread_count: u32, } /// Row in the project detail page (category listing). pub struct CategoryRow { pub name: String, pub slug: String, pub description: Option, pub thread_count: u32, } /// Tag badge for display in templates. pub struct TagBadge { pub id: String, pub name: String, pub slug: String, } /// Row in the dense thread table. pub struct ThreadRow { pub id: String, pub title: String, pub author_name: String, pub author_username: String, pub reply_count: u32, pub last_activity: String, pub pinned: bool, pub locked: bool, pub has_mention: bool, pub tags: Vec, } /// Footnote on a post. pub struct FootnoteViewRow { pub author_name: String, pub body_html: String, pub timestamp: String, } /// Link preview card for a post. pub struct LinkPreviewViewRow { pub url: String, pub title: Option, pub description: Option, } /// Single post in a thread. pub struct PostRow { pub id: String, pub author_name: String, pub author_username: String, pub timestamp: String, pub body_html: String, pub is_op: bool, pub is_removed: bool, pub can_restore: bool, pub can_add_footnote: bool, pub can_remove: bool, pub can_flag: bool, pub footnotes: Vec, pub link_previews: Vec, pub endorsement_count: u32, pub is_endorsed: bool, pub can_endorse: bool, /// Whether to show the `+` badge next to the author's name. True only for /// Fan+ subscribers; creators don't get the badge per platform spec. pub author_has_plus_badge: bool, /// Signature HTML to render below the post body. `None` if the author /// hasn't set one or has lost Fan+. pub author_signature_html: Option, } // Pagination /// Pagination state passed to templates. pub struct Pagination { pub current_page: u32, pub total_pages: u32, pub has_prev: bool, pub has_next: bool, /// Extra query string appended after `?page=N` in the prev/next links /// (e.g. `&filter=archived`), so a filtered listing stays filtered across /// pages. Empty by default; set with [`Pagination::with_query_suffix`]. pub query_suffix: String, } impl Pagination { pub fn new(page: u32, total_items: i64, per_page: i64) -> Self { let total_pages = ((total_items as f64) / (per_page as f64)).ceil() as u32; let total_pages = total_pages.max(1); let current_page = page.min(total_pages); Self { current_page, total_pages, has_prev: current_page > 1, has_next: current_page < total_pages, query_suffix: String::new(), } } /// Set the query-string suffix preserved across page links. The argument is /// the part after `?page=N` and must include its leading `&` (e.g. /// `&filter=archived`). #[must_use] pub fn with_query_suffix(mut self, suffix: impl Into) -> Self { self.query_suffix = suffix.into(); self } /// SQL OFFSET for the current page. `(current_page - 1) * per_page`, /// saturating so a clamped current_page can never wrap. pub fn offset(&self, per_page: i64) -> i64 { (self.current_page.saturating_sub(1) as i64) * per_page } } #[cfg(test)] mod pagination_tests { use super::Pagination; #[test] fn first_page_of_many() { let p = Pagination::new(1, 100, 25); assert_eq!(p.current_page, 1); assert_eq!(p.total_pages, 4); assert!(!p.has_prev); assert!(p.has_next); assert_eq!(p.offset(25), 0); } #[test] fn middle_page() { let p = Pagination::new(2, 100, 25); assert_eq!(p.current_page, 2); assert_eq!(p.total_pages, 4); assert!(p.has_prev); assert!(p.has_next); assert_eq!(p.offset(25), 25); } #[test] fn last_page() { let p = Pagination::new(4, 100, 25); assert_eq!(p.current_page, 4); assert_eq!(p.total_pages, 4); assert!(p.has_prev); assert!(!p.has_next, "last page must have has_next=false"); assert_eq!(p.offset(25), 75); } #[test] fn ceil_rounds_partial_final_page_up() { // 101 items at 25/page → ceil(101/25) = 5 pages, not 4. // Pins the `.ceil()` choice (vs `.floor()` or `.round()`). let p = Pagination::new(1, 101, 25); assert_eq!(p.total_pages, 5); let p_last = Pagination::new(5, 101, 25); assert_eq!(p_last.current_page, 5); assert!(!p_last.has_next); assert_eq!(p_last.offset(25), 100); } #[test] fn empty_collection_still_has_one_page() { // Pins the `.max(1)` floor on total_pages. let p = Pagination::new(1, 0, 25); assert_eq!(p.total_pages, 1); assert_eq!(p.current_page, 1); assert!(!p.has_prev); assert!(!p.has_next); assert_eq!(p.offset(25), 0); } #[test] fn page_beyond_total_is_clamped_to_last() { // Pins the `page.min(total_pages)` clamp. Request page 99 against 4 // total pages should land on page 4, not panic and not skip past. let p = Pagination::new(99, 100, 25); assert_eq!(p.current_page, 4); assert_eq!(p.total_pages, 4); assert!(!p.has_next); assert_eq!(p.offset(25), 75); } #[test] fn has_prev_is_strict_greater_than_one() { // Pins `current_page > 1` vs `>=`. Page 1 must have has_prev=false. let p1 = Pagination::new(1, 100, 25); assert!(!p1.has_prev); let p2 = Pagination::new(2, 100, 25); assert!(p2.has_prev); } #[test] fn has_next_is_strict_less_than_total() { // Pins `current_page < total_pages` vs `<=`. The final page must not // be its own next page. let p = Pagination::new(4, 100, 25); assert_eq!(p.current_page, 4); assert_eq!(p.total_pages, 4); assert!(!p.has_next, "page == total_pages must yield has_next=false"); } #[test] fn single_full_page() { // 25 items at 25/page → exactly 1 page. has_prev and has_next both false. let p = Pagination::new(1, 25, 25); assert_eq!(p.total_pages, 1); assert!(!p.has_prev); assert!(!p.has_next); } #[test] fn offset_for_clamped_page_does_not_wrap() { // If a caller passes page=0 (or page is otherwise clamped to 0), // offset() must not underflow. saturating_sub handles this. let p = Pagination { current_page: 0, total_pages: 1, has_prev: false, has_next: false, query_suffix: String::new(), }; assert_eq!(p.offset(25), 0); } } // Page templates /// Admin community detail page: state controls + clean-slate. #[derive(Template)] #[template(path = "pages/admin_community.html")] pub struct AdminCommunityTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub community_name: String, pub community_slug: String, /// Current state as a snake_case string (`active`/`restricted`/`frozen`/`archived`). pub current_state: &'static str, pub thread_count: i64, pub member_count: i64, pub is_suspended: bool, pub suspension_reason: Option, } /// Account settings: Fan+ signature editor and perk status. #[derive(Template)] #[template(path = "pages/account.html")] pub struct AccountSettingsTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, /// Whether the viewer has Fan+ perks (incl. creator auto-grant). Drives /// whether the signature form is editable or shows the upsell. pub has_plus: bool, /// Direct Fan+ subscription (distinct from creator auto-grant). pub fan_plus: bool, /// Currently saved signature markdown (None if unset). pub signature_markdown: Option, /// Rendered preview of the saved signature. pub signature_html: Option, } /// Forum directory: lists local communities. #[derive(Template)] #[template(path = "pages/forum_directory.html")] pub struct ForumDirectoryTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub communities: Vec, pub pagination: Pagination, /// True when viewing the archived-only listing (`?filter=archived`). pub viewing_archived: bool, } /// Project forum: category table within a single project. #[derive(Template)] #[template(path = "pages/community.html")] pub struct CommunityTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub community_name: String, pub community_slug: String, pub community_description: Option, pub categories: Vec, pub is_owner: bool, pub is_mod_or_owner: bool, /// Whether to offer the chat link. False when the room would 404: chat off, /// or the community suspended. `off` must be total, so no affordance is part /// of the policy rather than a nicety. pub chat_open: bool, } /// Category view: dense thread table (the signature UI). #[derive(Template)] #[template(path = "pages/category.html")] pub struct CategoryTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub community_name: String, pub community_slug: String, pub category_name: String, pub category_slug: String, pub threads: Vec, pub pagination: Pagination, pub sort_column: String, pub sort_order: String, pub available_tags: Vec, pub active_tag: Option, } /// Thread view: post list with reply form. #[derive(Template)] #[template(path = "pages/thread.html")] pub struct ThreadTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub community_name: String, pub community_slug: String, pub category_name: String, pub category_slug: String, pub thread_id: String, pub thread_title: String, pub locked: bool, pub pinned: bool, pub is_mod: bool, pub can_mod_thread: bool, pub is_tracked: bool, pub posts: Vec, pub pagination: Pagination, } /// New thread creation form. #[derive(Template)] #[template(path = "pages/new_thread.html")] pub struct NewThreadTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub community_name: String, pub community_slug: String, pub category_name: String, pub category_slug: String, pub available_tags: Vec, } /// Edit thread title form. #[derive(Template)] #[template(path = "pages/edit_thread.html")] pub struct EditThreadTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub community_name: String, pub community_slug: String, pub category_name: String, pub category_slug: String, pub thread_id: String, pub current_title: String, } /// Category row for the settings page. pub struct SettingsCategoryRow { pub id: String, pub name: String, pub slug: String, pub description: Option, pub sort_order: i32, pub is_first: bool, pub is_last: bool, } /// Community settings page (owner only). #[derive(Template)] #[template(path = "pages/community_settings.html")] pub struct CommunitySettingsTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub community_name: String, pub community_slug: String, pub community_description: Option, pub auto_hide_threshold: Option, /// The room's current policy, as the `