Skip to main content

max / makenotwork

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