Skip to main content

max / makenotwork

19.6 KB · 670 lines History Blame Raw
1 //! Templates for public-facing forum pages.
2
3 use askama::Template;
4
5 use super::CsrfTokenOption;
6
7 // View-model structs (lightweight data for templates, not domain models)
8
9 /// Minimal user info for the site header.
10 pub struct TemplateSessionUser {
11 pub username: String,
12 pub is_platform_admin: bool,
13 }
14
15 /// Row in the forum directory (home page).
16 pub struct CommunityDirectoryRow {
17 pub slug: String,
18 pub name: String,
19 pub description: Option<String>,
20 pub category_count: u32,
21 pub thread_count: u32,
22 }
23
24 /// Row in the project detail page (category listing).
25 pub struct CategoryRow {
26 pub name: String,
27 pub slug: String,
28 pub description: Option<String>,
29 pub thread_count: u32,
30 }
31
32 /// Tag badge for display in templates.
33 pub struct TagBadge {
34 pub id: String,
35 pub name: String,
36 pub slug: String,
37 }
38
39 /// Row in the dense thread table.
40 pub struct ThreadRow {
41 pub id: String,
42 pub title: String,
43 pub author_name: String,
44 pub author_username: String,
45 pub reply_count: u32,
46 pub last_activity: String,
47 pub pinned: bool,
48 pub locked: bool,
49 pub has_mention: bool,
50 pub tags: Vec<TagBadge>,
51 }
52
53 /// Footnote on a post.
54 pub struct FootnoteViewRow {
55 pub author_name: String,
56 pub body_html: String,
57 pub timestamp: String,
58 }
59
60 /// Link preview card for a post.
61 pub struct LinkPreviewViewRow {
62 pub url: String,
63 pub title: Option<String>,
64 pub description: Option<String>,
65 }
66
67 /// Single post in a thread.
68 pub struct PostRow {
69 pub id: String,
70 pub author_name: String,
71 pub author_username: String,
72 pub timestamp: String,
73 pub body_html: String,
74 pub is_op: bool,
75 pub is_removed: bool,
76 pub can_restore: bool,
77 pub can_add_footnote: bool,
78 pub can_remove: bool,
79 pub can_flag: bool,
80 pub footnotes: Vec<FootnoteViewRow>,
81 pub link_previews: Vec<LinkPreviewViewRow>,
82 pub endorsement_count: u32,
83 pub is_endorsed: bool,
84 pub can_endorse: bool,
85 /// Whether to show the `+` badge next to the author's name. True only for
86 /// Fan+ subscribers; creators don't get the badge per platform spec.
87 pub author_has_plus_badge: bool,
88 /// Signature HTML to render below the post body. `None` if the author
89 /// hasn't set one or has lost Fan+.
90 pub author_signature_html: Option<String>,
91 }
92
93 // Pagination
94
95 /// Pagination state passed to templates.
96 pub struct Pagination {
97 pub current_page: u32,
98 pub total_pages: u32,
99 pub has_prev: bool,
100 pub has_next: bool,
101 /// Extra query string appended after `?page=N` in the prev/next links
102 /// (e.g. `&filter=archived`), so a filtered listing stays filtered across
103 /// pages. Empty by default; set with [`Pagination::with_query_suffix`].
104 pub query_suffix: String,
105 }
106
107 impl Pagination {
108 pub fn new(page: u32, total_items: i64, per_page: i64) -> Self {
109 let total_pages = ((total_items as f64) / (per_page as f64)).ceil() as u32;
110 let total_pages = total_pages.max(1);
111 let current_page = page.min(total_pages);
112 Self {
113 current_page,
114 total_pages,
115 has_prev: current_page > 1,
116 has_next: current_page < total_pages,
117 query_suffix: String::new(),
118 }
119 }
120
121 /// Set the query-string suffix preserved across page links. The argument is
122 /// the part after `?page=N` and must include its leading `&` (e.g.
123 /// `&filter=archived`).
124 #[must_use]
125 pub fn with_query_suffix(mut self, suffix: impl Into<String>) -> Self {
126 self.query_suffix = suffix.into();
127 self
128 }
129
130 /// SQL OFFSET for the current page. `(current_page - 1) * per_page`,
131 /// saturating so a clamped current_page can never wrap.
132 pub fn offset(&self, per_page: i64) -> i64 {
133 (self.current_page.saturating_sub(1) as i64) * per_page
134 }
135 }
136
137 #[cfg(test)]
138 mod pagination_tests {
139 use super::Pagination;
140
141 #[test]
142 fn first_page_of_many() {
143 let p = Pagination::new(1, 100, 25);
144 assert_eq!(p.current_page, 1);
145 assert_eq!(p.total_pages, 4);
146 assert!(!p.has_prev);
147 assert!(p.has_next);
148 assert_eq!(p.offset(25), 0);
149 }
150
151 #[test]
152 fn middle_page() {
153 let p = Pagination::new(2, 100, 25);
154 assert_eq!(p.current_page, 2);
155 assert_eq!(p.total_pages, 4);
156 assert!(p.has_prev);
157 assert!(p.has_next);
158 assert_eq!(p.offset(25), 25);
159 }
160
161 #[test]
162 fn last_page() {
163 let p = Pagination::new(4, 100, 25);
164 assert_eq!(p.current_page, 4);
165 assert_eq!(p.total_pages, 4);
166 assert!(p.has_prev);
167 assert!(!p.has_next, "last page must have has_next=false");
168 assert_eq!(p.offset(25), 75);
169 }
170
171 #[test]
172 fn ceil_rounds_partial_final_page_up() {
173 // 101 items at 25/page → ceil(101/25) = 5 pages, not 4.
174 // Pins the `.ceil()` choice (vs `.floor()` or `.round()`).
175 let p = Pagination::new(1, 101, 25);
176 assert_eq!(p.total_pages, 5);
177 let p_last = Pagination::new(5, 101, 25);
178 assert_eq!(p_last.current_page, 5);
179 assert!(!p_last.has_next);
180 assert_eq!(p_last.offset(25), 100);
181 }
182
183 #[test]
184 fn empty_collection_still_has_one_page() {
185 // Pins the `.max(1)` floor on total_pages.
186 let p = Pagination::new(1, 0, 25);
187 assert_eq!(p.total_pages, 1);
188 assert_eq!(p.current_page, 1);
189 assert!(!p.has_prev);
190 assert!(!p.has_next);
191 assert_eq!(p.offset(25), 0);
192 }
193
194 #[test]
195 fn page_beyond_total_is_clamped_to_last() {
196 // Pins the `page.min(total_pages)` clamp. Request page 99 against 4
197 // total pages should land on page 4, not panic and not skip past.
198 let p = Pagination::new(99, 100, 25);
199 assert_eq!(p.current_page, 4);
200 assert_eq!(p.total_pages, 4);
201 assert!(!p.has_next);
202 assert_eq!(p.offset(25), 75);
203 }
204
205 #[test]
206 fn has_prev_is_strict_greater_than_one() {
207 // Pins `current_page > 1` vs `>=`. Page 1 must have has_prev=false.
208 let p1 = Pagination::new(1, 100, 25);
209 assert!(!p1.has_prev);
210 let p2 = Pagination::new(2, 100, 25);
211 assert!(p2.has_prev);
212 }
213
214 #[test]
215 fn has_next_is_strict_less_than_total() {
216 // Pins `current_page < total_pages` vs `<=`. The final page must not
217 // be its own next page.
218 let p = Pagination::new(4, 100, 25);
219 assert_eq!(p.current_page, 4);
220 assert_eq!(p.total_pages, 4);
221 assert!(!p.has_next, "page == total_pages must yield has_next=false");
222 }
223
224 #[test]
225 fn single_full_page() {
226 // 25 items at 25/page → exactly 1 page. has_prev and has_next both false.
227 let p = Pagination::new(1, 25, 25);
228 assert_eq!(p.total_pages, 1);
229 assert!(!p.has_prev);
230 assert!(!p.has_next);
231 }
232
233 #[test]
234 fn offset_for_clamped_page_does_not_wrap() {
235 // If a caller passes page=0 (or page is otherwise clamped to 0),
236 // offset() must not underflow. saturating_sub handles this.
237 let p = Pagination {
238 current_page: 0,
239 total_pages: 1,
240 has_prev: false,
241 has_next: false,
242 query_suffix: String::new(),
243 };
244 assert_eq!(p.offset(25), 0);
245 }
246 }
247
248 // Page templates
249
250 /// Admin community detail page: state controls + clean-slate.
251 #[derive(Template)]
252 #[template(path = "pages/admin_community.html")]
253 pub struct AdminCommunityTemplate {
254 pub csrf_token: CsrfTokenOption,
255 pub session_user: Option<TemplateSessionUser>,
256 pub mnw_base_url: std::sync::Arc<str>,
257 pub community_name: String,
258 pub community_slug: String,
259 /// Current state as a snake_case string (`active`/`restricted`/`frozen`/`archived`).
260 pub current_state: &'static str,
261 pub thread_count: i64,
262 pub member_count: i64,
263 pub is_suspended: bool,
264 pub suspension_reason: Option<String>,
265 }
266
267 /// Account settings: Fan+ signature editor and perk status.
268 #[derive(Template)]
269 #[template(path = "pages/account.html")]
270 pub struct AccountSettingsTemplate {
271 pub csrf_token: CsrfTokenOption,
272 pub session_user: Option<TemplateSessionUser>,
273 pub mnw_base_url: std::sync::Arc<str>,
274 /// Whether the viewer has Fan+ perks (incl. creator auto-grant). Drives
275 /// whether the signature form is editable or shows the upsell.
276 pub has_plus: bool,
277 /// Direct Fan+ subscription (distinct from creator auto-grant).
278 pub fan_plus: bool,
279 /// Currently saved signature markdown (None if unset).
280 pub signature_markdown: Option<String>,
281 /// Rendered preview of the saved signature.
282 pub signature_html: Option<String>,
283 }
284
285 /// Forum directory: lists local communities.
286 #[derive(Template)]
287 #[template(path = "pages/forum_directory.html")]
288 pub struct ForumDirectoryTemplate {
289 pub csrf_token: CsrfTokenOption,
290 pub session_user: Option<TemplateSessionUser>,
291 pub mnw_base_url: std::sync::Arc<str>,
292 pub communities: Vec<CommunityDirectoryRow>,
293 pub pagination: Pagination,
294 /// True when viewing the archived-only listing (`?filter=archived`).
295 pub viewing_archived: bool,
296 }
297
298 /// Project forum: category table within a single project.
299 #[derive(Template)]
300 #[template(path = "pages/community.html")]
301 pub struct CommunityTemplate {
302 pub csrf_token: CsrfTokenOption,
303 pub session_user: Option<TemplateSessionUser>,
304 pub mnw_base_url: std::sync::Arc<str>,
305 pub community_name: String,
306 pub community_slug: String,
307 pub community_description: Option<String>,
308 pub categories: Vec<CategoryRow>,
309 pub is_owner: bool,
310 pub is_mod_or_owner: bool,
311 }
312
313 /// Category view: dense thread table (the signature UI).
314 #[derive(Template)]
315 #[template(path = "pages/category.html")]
316 pub struct CategoryTemplate {
317 pub csrf_token: CsrfTokenOption,
318 pub session_user: Option<TemplateSessionUser>,
319 pub mnw_base_url: std::sync::Arc<str>,
320 pub community_name: String,
321 pub community_slug: String,
322 pub category_name: String,
323 pub category_slug: String,
324 pub threads: Vec<ThreadRow>,
325 pub pagination: Pagination,
326 pub sort_column: String,
327 pub sort_order: String,
328 pub available_tags: Vec<TagBadge>,
329 pub active_tag: Option<String>,
330 }
331
332 /// Thread view: post list with reply form.
333 #[derive(Template)]
334 #[template(path = "pages/thread.html")]
335 pub struct ThreadTemplate {
336 pub csrf_token: CsrfTokenOption,
337 pub session_user: Option<TemplateSessionUser>,
338 pub mnw_base_url: std::sync::Arc<str>,
339 pub community_name: String,
340 pub community_slug: String,
341 pub category_name: String,
342 pub category_slug: String,
343 pub thread_id: String,
344 pub thread_title: String,
345 pub locked: bool,
346 pub pinned: bool,
347 pub is_mod: bool,
348 pub can_mod_thread: bool,
349 pub is_tracked: bool,
350 pub posts: Vec<PostRow>,
351 pub pagination: Pagination,
352 }
353
354 /// New thread creation form.
355 #[derive(Template)]
356 #[template(path = "pages/new_thread.html")]
357 pub struct NewThreadTemplate {
358 pub csrf_token: CsrfTokenOption,
359 pub session_user: Option<TemplateSessionUser>,
360 pub mnw_base_url: std::sync::Arc<str>,
361 pub community_name: String,
362 pub community_slug: String,
363 pub category_name: String,
364 pub category_slug: String,
365 pub available_tags: Vec<TagBadge>,
366 }
367
368 /// Edit thread title form.
369 #[derive(Template)]
370 #[template(path = "pages/edit_thread.html")]
371 pub struct EditThreadTemplate {
372 pub csrf_token: CsrfTokenOption,
373 pub session_user: Option<TemplateSessionUser>,
374 pub mnw_base_url: std::sync::Arc<str>,
375 pub community_name: String,
376 pub community_slug: String,
377 pub category_name: String,
378 pub category_slug: String,
379 pub thread_id: String,
380 pub current_title: String,
381 }
382
383 /// Category row for the settings page.
384 pub struct SettingsCategoryRow {
385 pub id: String,
386 pub name: String,
387 pub slug: String,
388 pub description: Option<String>,
389 pub sort_order: i32,
390 pub is_first: bool,
391 pub is_last: bool,
392 }
393
394 /// Community settings page (owner only).
395 #[derive(Template)]
396 #[template(path = "pages/community_settings.html")]
397 pub struct CommunitySettingsTemplate {
398 pub csrf_token: CsrfTokenOption,
399 pub session_user: Option<TemplateSessionUser>,
400 pub mnw_base_url: std::sync::Arc<str>,
401 pub community_name: String,
402 pub community_slug: String,
403 pub community_description: Option<String>,
404 pub auto_hide_threshold: Option<i32>,
405 pub categories: Vec<SettingsCategoryRow>,
406 pub tags: Vec<TagBadge>,
407 }
408
409 /// Edit category form (owner only).
410 #[derive(Template)]
411 #[template(path = "pages/edit_category.html")]
412 pub struct EditCategoryTemplate {
413 pub csrf_token: CsrfTokenOption,
414 pub session_user: Option<TemplateSessionUser>,
415 pub mnw_base_url: std::sync::Arc<str>,
416 pub community_name: String,
417 pub community_slug: String,
418 pub category_id: String,
419 pub category_name: String,
420 pub category_description: Option<String>,
421 }
422
423 /// Row in the member list.
424 pub struct MemberListRow {
425 pub username: String,
426 pub display_name: String,
427 pub role: String,
428 pub joined: String,
429 }
430
431 /// Community member list page.
432 #[derive(Template)]
433 #[template(path = "pages/members.html")]
434 pub struct MembersTemplate {
435 pub csrf_token: CsrfTokenOption,
436 pub session_user: Option<TemplateSessionUser>,
437 pub mnw_base_url: std::sync::Arc<str>,
438 pub community_name: String,
439 pub community_slug: String,
440 pub members: Vec<MemberListRow>,
441 pub pagination: Pagination,
442 }
443
444 /// Activity row for user profile page.
445 pub struct ProfileActivityRow {
446 pub thread_id: String,
447 pub thread_title: String,
448 pub category_name: String,
449 pub category_slug: String,
450 pub timestamp: String,
451 pub is_thread_author: bool,
452 }
453
454 /// User profile within a community.
455 #[derive(Template)]
456 #[template(path = "pages/user_profile.html")]
457 pub struct UserProfileTemplate {
458 pub csrf_token: CsrfTokenOption,
459 pub session_user: Option<TemplateSessionUser>,
460 pub mnw_base_url: std::sync::Arc<str>,
461 pub community_name: String,
462 pub community_slug: String,
463 pub username: String,
464 pub display_name: String,
465 pub avatar_url: Option<String>,
466 pub role: String,
467 pub joined: String,
468 pub post_count: i64,
469 pub endorsement_count: i64,
470 pub activity: Vec<ProfileActivityRow>,
471 }
472
473 /// Row in tracked threads list.
474 pub struct TrackedThreadViewRow {
475 pub thread_id: String,
476 pub thread_title: String,
477 pub community_name: String,
478 pub community_slug: String,
479 pub category_slug: String,
480 pub unread_count: u32,
481 pub has_mention: bool,
482 }
483
484 /// Tracked threads page.
485 #[derive(Template)]
486 #[template(path = "pages/tracked.html")]
487 pub struct TrackedThreadsTemplate {
488 pub csrf_token: CsrfTokenOption,
489 pub session_user: Option<TemplateSessionUser>,
490 pub mnw_base_url: std::sync::Arc<str>,
491 pub threads: Vec<TrackedThreadViewRow>,
492 pub pagination: Pagination,
493 }
494
495 // Search templates
496
497 /// Single search result row.
498 pub struct SearchResultViewRow {
499 pub thread_id: String,
500 pub thread_title: String,
501 pub author_username: String,
502 pub community_name: String,
503 pub community_slug: String,
504 pub category_name: String,
505 pub category_slug: String,
506 pub snippet: String,
507 pub last_activity: String,
508 }
509
510 /// HTMX fragment: search results list.
511 #[derive(Template)]
512 #[template(path = "fragments/search_results.html")]
513 pub struct SearchResultsFragment {
514 pub results: Vec<SearchResultViewRow>,
515 }
516
517 /// Privacy/tracking info page.
518 #[derive(Template)]
519 #[template(path = "pages/tracking_info.html")]
520 pub struct TrackingInfoTemplate {
521 pub csrf_token: CsrfTokenOption,
522 pub session_user: Option<TemplateSessionUser>,
523 pub mnw_base_url: std::sync::Arc<str>,
524 }
525
526 /// 404 error page.
527 #[derive(Template)]
528 #[template(path = "pages/error_404.html")]
529 pub struct Error404Template {
530 pub csrf_token: CsrfTokenOption,
531 pub session_user: Option<TemplateSessionUser>,
532 pub mnw_base_url: std::sync::Arc<str>,
533 }
534
535 /// 500 error page.
536 #[derive(Template)]
537 #[template(path = "pages/error_500.html")]
538 pub struct Error500Template {
539 pub csrf_token: CsrfTokenOption,
540 pub session_user: Option<TemplateSessionUser>,
541 pub mnw_base_url: std::sync::Arc<str>,
542 }
543
544 // Moderation templates
545
546 /// Row in the active bans/mutes table.
547 pub struct BanListRow {
548 pub username: String,
549 pub display_name: Option<String>,
550 pub ban_type: String,
551 pub reason: Option<String>,
552 pub expires: Option<String>,
553 pub created: String,
554 pub banned_by: String,
555 }
556
557 /// Row in the mod log.
558 pub struct ModLogRow {
559 pub actor: String,
560 pub action: String,
561 pub target: Option<String>,
562 pub reason: Option<String>,
563 pub timestamp: String,
564 }
565
566 /// Pending flag for moderation page.
567 pub struct FlagViewRow {
568 pub flag_id: String,
569 pub post_id: String,
570 pub thread_id: String,
571 pub thread_title: String,
572 pub category_slug: String,
573 pub flagger_username: String,
574 pub reason: String,
575 pub detail: Option<String>,
576 pub created: String,
577 }
578
579 /// Community moderation page (mod/owner only).
580 #[derive(Template)]
581 #[template(path = "pages/moderation.html")]
582 pub struct ModerationTemplate {
583 pub csrf_token: CsrfTokenOption,
584 pub session_user: Option<TemplateSessionUser>,
585 pub mnw_base_url: std::sync::Arc<str>,
586 pub community_name: String,
587 pub community_slug: String,
588 pub bans: Vec<BanListRow>,
589 /// True when more active bans exist than the page renders (capped read).
590 pub bans_truncated: bool,
591 pub pending_flags: Vec<FlagViewRow>,
592 /// True when more pending flags exist than the page renders (capped read).
593 pub flags_truncated: bool,
594 pub is_owner: bool,
595 }
596
597 /// Soft-deleted thread awaiting restore or nothing.
598 pub struct DeletedThreadViewRow {
599 pub thread_id: String,
600 pub title: String,
601 pub category_slug: String,
602 pub author_username: String,
603 pub deleted: String,
604 /// Restoring also brings the opening post back, worth saying on the button
605 /// so the mod knows the scope of what they are undoing.
606 pub op_removed: bool,
607 }
608
609 /// Deleted-threads page (mod/owner only).
610 #[derive(Template)]
611 #[template(path = "pages/deleted_threads.html")]
612 pub struct DeletedThreadsTemplate {
613 pub csrf_token: CsrfTokenOption,
614 pub session_user: Option<TemplateSessionUser>,
615 pub mnw_base_url: std::sync::Arc<str>,
616 pub community_name: String,
617 pub community_slug: String,
618 pub threads: Vec<DeletedThreadViewRow>,
619 /// True when more deleted threads exist than the page renders (capped read).
620 pub threads_truncated: bool,
621 }
622
623 /// Mod log page (mod/owner only).
624 #[derive(Template)]
625 #[template(path = "pages/mod_log.html")]
626 pub struct ModLogTemplate {
627 pub csrf_token: CsrfTokenOption,
628 pub session_user: Option<TemplateSessionUser>,
629 pub mnw_base_url: std::sync::Arc<str>,
630 pub community_name: String,
631 pub community_slug: String,
632 pub entries: Vec<ModLogRow>,
633 pub pagination: Pagination,
634 }
635
636 // Admin templates
637
638 /// Row for communities in admin dashboard.
639 pub struct AdminCommunityViewRow {
640 pub id: String,
641 pub name: String,
642 pub slug: String,
643 pub is_suspended: bool,
644 pub suspension_reason: Option<String>,
645 }
646
647 /// Row for users in admin dashboard.
648 pub struct AdminUserViewRow {
649 pub id: String,
650 pub username: String,
651 pub display_name: Option<String>,
652 pub is_suspended: bool,
653 pub suspension_reason: Option<String>,
654 }
655
656 /// Platform admin dashboard.
657 #[derive(Template)]
658 #[template(path = "pages/admin.html")]
659 pub struct AdminDashboardTemplate {
660 pub csrf_token: CsrfTokenOption,
661 pub session_user: Option<TemplateSessionUser>,
662 pub mnw_base_url: std::sync::Arc<str>,
663 pub communities: Vec<AdminCommunityViewRow>,
664 /// True when more communities exist than the page caps at, so the template
665 /// can say so instead of silently dropping the tail.
666 pub communities_truncated: bool,
667 pub users: Vec<AdminUserViewRow>,
668 pub search_query: String,
669 }
670