//! 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_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, } 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, } } /// 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, }; 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, /// Server-side validation error to surface above the form. pub error: 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, } /// 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, pub categories: Vec, pub tags: Vec, } /// Edit category form (owner only). #[derive(Template)] #[template(path = "pages/edit_category.html")] pub struct EditCategoryTemplate { 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_id: String, pub category_name: String, pub category_description: Option, } /// Row in the member list. pub struct MemberListRow { pub username: String, pub display_name: String, pub role: String, pub joined: String, } /// Community member list page. #[derive(Template)] #[template(path = "pages/members.html")] pub struct MembersTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub community_name: String, pub community_slug: String, pub members: Vec, } /// Activity row for user profile page. pub struct ProfileActivityRow { pub thread_id: String, pub thread_title: String, pub category_name: String, pub category_slug: String, pub timestamp: String, pub is_thread_author: bool, } /// User profile within a community. #[derive(Template)] #[template(path = "pages/user_profile.html")] pub struct UserProfileTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub community_name: String, pub community_slug: String, pub username: String, pub display_name: String, pub avatar_url: Option, pub role: String, pub joined: String, pub post_count: i64, pub endorsement_count: i64, pub activity: Vec, } /// Row in tracked threads list. pub struct TrackedThreadViewRow { pub thread_id: String, pub thread_title: String, pub community_name: String, pub community_slug: String, pub category_slug: String, pub unread_count: u32, pub has_mention: bool, } /// Tracked threads page. #[derive(Template)] #[template(path = "pages/tracked.html")] pub struct TrackedThreadsTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub threads: Vec, } // ============================================================================ // Search templates // ============================================================================ /// Single search result row. pub struct SearchResultViewRow { pub thread_id: String, pub thread_title: String, pub author_username: String, pub community_name: String, pub community_slug: String, pub category_name: String, pub category_slug: String, pub snippet: String, pub last_activity: String, } /// HTMX fragment — search results list. #[derive(Template)] #[template(path = "fragments/search_results.html")] pub struct SearchResultsFragment { pub results: Vec, } /// Privacy/tracking info page. #[derive(Template)] #[template(path = "pages/tracking_info.html")] pub struct TrackingInfoTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, } /// 404 error page. #[derive(Template)] #[template(path = "pages/error_404.html")] pub struct Error404Template { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, } /// 500 error page. #[derive(Template)] #[template(path = "pages/error_500.html")] pub struct Error500Template { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, } // ============================================================================ // Moderation templates // ============================================================================ /// Row in the active bans/mutes table. pub struct BanListRow { pub username: String, pub display_name: Option, pub ban_type: String, pub reason: Option, pub expires: Option, pub created: String, pub banned_by: String, } /// Row in the mod log. pub struct ModLogRow { pub actor: String, pub action: String, pub target: Option, pub reason: Option, pub timestamp: String, } /// Pending flag for moderation page. pub struct FlagViewRow { pub flag_id: String, pub post_id: String, pub thread_id: String, pub thread_title: String, pub category_slug: String, pub flagger_username: String, pub reason: String, pub detail: Option, pub created: String, } /// Community moderation page (mod/owner only). #[derive(Template)] #[template(path = "pages/moderation.html")] pub struct ModerationTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub community_name: String, pub community_slug: String, pub bans: Vec, pub pending_flags: Vec, pub is_owner: bool, } /// Mod log page (mod/owner only). #[derive(Template)] #[template(path = "pages/mod_log.html")] pub struct ModLogTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub community_name: String, pub community_slug: String, pub entries: Vec, pub pagination: Pagination, } // ============================================================================ // Admin templates // ============================================================================ /// Row for communities in admin dashboard. pub struct AdminCommunityViewRow { pub id: String, pub name: String, pub slug: String, pub is_suspended: bool, pub suspension_reason: Option, } /// Row for users in admin dashboard. pub struct AdminUserViewRow { pub id: String, pub username: String, pub display_name: Option, pub is_suspended: bool, pub suspension_reason: Option, } /// Platform admin dashboard. #[derive(Template)] #[template(path = "pages/admin.html")] pub struct AdminDashboardTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub mnw_base_url: std::sync::Arc, pub communities: Vec, pub users: Vec, pub search_query: String, }