Skip to main content

max / makenotwork

22.9 KB · 755 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 /// Whether to offer the chat link. False when the room would 404: chat off,
312 /// or the community suspended. `off` must be total, so no affordance is part
313 /// of the policy rather than a nicety.
314 pub chat_open: bool,
315 }
316
317 /// Category view: dense thread table (the signature UI).
318 #[derive(Template)]
319 #[template(path = "pages/category.html")]
320 pub struct CategoryTemplate {
321 pub csrf_token: CsrfTokenOption,
322 pub session_user: Option<TemplateSessionUser>,
323 pub mnw_base_url: std::sync::Arc<str>,
324 pub community_name: String,
325 pub community_slug: String,
326 pub category_name: String,
327 pub category_slug: String,
328 pub threads: Vec<ThreadRow>,
329 pub pagination: Pagination,
330 pub sort_column: String,
331 pub sort_order: String,
332 pub available_tags: Vec<TagBadge>,
333 pub active_tag: Option<String>,
334 }
335
336 /// Thread view: post list with reply form.
337 #[derive(Template)]
338 #[template(path = "pages/thread.html")]
339 pub struct ThreadTemplate {
340 pub csrf_token: CsrfTokenOption,
341 pub session_user: Option<TemplateSessionUser>,
342 pub mnw_base_url: std::sync::Arc<str>,
343 pub community_name: String,
344 pub community_slug: String,
345 pub category_name: String,
346 pub category_slug: String,
347 pub thread_id: String,
348 pub thread_title: String,
349 pub locked: bool,
350 pub pinned: bool,
351 pub is_mod: bool,
352 pub can_mod_thread: bool,
353 pub is_tracked: bool,
354 pub posts: Vec<PostRow>,
355 pub pagination: Pagination,
356 }
357
358 /// New thread creation form.
359 #[derive(Template)]
360 #[template(path = "pages/new_thread.html")]
361 pub struct NewThreadTemplate {
362 pub csrf_token: CsrfTokenOption,
363 pub session_user: Option<TemplateSessionUser>,
364 pub mnw_base_url: std::sync::Arc<str>,
365 pub community_name: String,
366 pub community_slug: String,
367 pub category_name: String,
368 pub category_slug: String,
369 pub available_tags: Vec<TagBadge>,
370 }
371
372 /// Edit thread title form.
373 #[derive(Template)]
374 #[template(path = "pages/edit_thread.html")]
375 pub struct EditThreadTemplate {
376 pub csrf_token: CsrfTokenOption,
377 pub session_user: Option<TemplateSessionUser>,
378 pub mnw_base_url: std::sync::Arc<str>,
379 pub community_name: String,
380 pub community_slug: String,
381 pub category_name: String,
382 pub category_slug: String,
383 pub thread_id: String,
384 pub current_title: String,
385 }
386
387 /// Category row for the settings page.
388 pub struct SettingsCategoryRow {
389 pub id: String,
390 pub name: String,
391 pub slug: String,
392 pub description: Option<String>,
393 pub sort_order: i32,
394 pub is_first: bool,
395 pub is_last: bool,
396 }
397
398 /// Community settings page (owner only).
399 #[derive(Template)]
400 #[template(path = "pages/community_settings.html")]
401 pub struct CommunitySettingsTemplate {
402 pub csrf_token: CsrfTokenOption,
403 pub session_user: Option<TemplateSessionUser>,
404 pub mnw_base_url: std::sync::Arc<str>,
405 pub community_name: String,
406 pub community_slug: String,
407 pub community_description: Option<String>,
408 pub auto_hide_threshold: Option<i32>,
409 /// The room's current policy, as the `<select>` compares it.
410 pub chat_policy: &'static str,
411 /// Every policy, so the form is generated from the enum rather than from a
412 /// hand-written list that a fifth variant would silently leave behind.
413 pub chat_policies: [mt_core::types::ChatPolicy; 4],
414 pub chat_retention_hours: i32,
415 pub chat_max_messages: i32,
416 /// The crate's ceilings, as the inputs' `max` attributes. Carried rather
417 /// than written into the template so raising a ceiling in `livechat` raises
418 /// the form with it.
419 pub chat_max_retention_hours: i32,
420 pub chat_message_ceiling: usize,
421 pub categories: Vec<SettingsCategoryRow>,
422 pub tags: Vec<TagBadge>,
423 }
424
425 /// Edit category form (owner only).
426 #[derive(Template)]
427 #[template(path = "pages/edit_category.html")]
428 pub struct EditCategoryTemplate {
429 pub csrf_token: CsrfTokenOption,
430 pub session_user: Option<TemplateSessionUser>,
431 pub mnw_base_url: std::sync::Arc<str>,
432 pub community_name: String,
433 pub community_slug: String,
434 pub category_id: String,
435 pub category_name: String,
436 pub category_description: Option<String>,
437 }
438
439 /// Row in the member list.
440 pub struct MemberListRow {
441 pub username: String,
442 pub display_name: String,
443 pub role: String,
444 pub joined: String,
445 }
446
447 /// Community member list page.
448 #[derive(Template)]
449 #[template(path = "pages/members.html")]
450 pub struct MembersTemplate {
451 pub csrf_token: CsrfTokenOption,
452 pub session_user: Option<TemplateSessionUser>,
453 pub mnw_base_url: std::sync::Arc<str>,
454 pub community_name: String,
455 pub community_slug: String,
456 pub members: Vec<MemberListRow>,
457 pub pagination: Pagination,
458 }
459
460 /// Activity row for user profile page.
461 pub struct ProfileActivityRow {
462 pub thread_id: String,
463 pub thread_title: String,
464 pub category_name: String,
465 pub category_slug: String,
466 pub timestamp: String,
467 pub is_thread_author: bool,
468 }
469
470 /// User profile within a community.
471 #[derive(Template)]
472 #[template(path = "pages/user_profile.html")]
473 pub struct UserProfileTemplate {
474 pub csrf_token: CsrfTokenOption,
475 pub session_user: Option<TemplateSessionUser>,
476 pub mnw_base_url: std::sync::Arc<str>,
477 pub community_name: String,
478 pub community_slug: String,
479 pub username: String,
480 pub display_name: String,
481 pub avatar_url: Option<String>,
482 pub role: String,
483 pub joined: String,
484 pub post_count: i64,
485 pub endorsement_count: i64,
486 pub activity: Vec<ProfileActivityRow>,
487 }
488
489 /// Row in tracked threads list.
490 pub struct TrackedThreadViewRow {
491 pub thread_id: String,
492 pub thread_title: String,
493 pub community_name: String,
494 pub community_slug: String,
495 pub category_slug: String,
496 pub unread_count: u32,
497 pub has_mention: bool,
498 }
499
500 /// Tracked threads page.
501 #[derive(Template)]
502 #[template(path = "pages/tracked.html")]
503 pub struct TrackedThreadsTemplate {
504 pub csrf_token: CsrfTokenOption,
505 pub session_user: Option<TemplateSessionUser>,
506 pub mnw_base_url: std::sync::Arc<str>,
507 pub threads: Vec<TrackedThreadViewRow>,
508 pub pagination: Pagination,
509 }
510
511 // Search templates
512
513 /// Single search result row.
514 pub struct SearchResultViewRow {
515 pub thread_id: String,
516 pub thread_title: String,
517 pub author_username: String,
518 pub community_name: String,
519 pub community_slug: String,
520 pub category_name: String,
521 pub category_slug: String,
522 pub snippet: String,
523 pub last_activity: String,
524 }
525
526 /// HTMX fragment: search results list.
527 #[derive(Template)]
528 #[template(path = "fragments/search_results.html")]
529 pub struct SearchResultsFragment {
530 pub results: Vec<SearchResultViewRow>,
531 }
532
533 /// Privacy/tracking info page.
534 #[derive(Template)]
535 #[template(path = "pages/tracking_info.html")]
536 pub struct TrackingInfoTemplate {
537 pub csrf_token: CsrfTokenOption,
538 pub session_user: Option<TemplateSessionUser>,
539 pub mnw_base_url: std::sync::Arc<str>,
540 }
541
542 /// 404 error page.
543 #[derive(Template)]
544 #[template(path = "pages/error_404.html")]
545 pub struct Error404Template {
546 pub csrf_token: CsrfTokenOption,
547 pub session_user: Option<TemplateSessionUser>,
548 pub mnw_base_url: std::sync::Arc<str>,
549 }
550
551 /// 500 error page.
552 #[derive(Template)]
553 #[template(path = "pages/error_500.html")]
554 pub struct Error500Template {
555 pub csrf_token: CsrfTokenOption,
556 pub session_user: Option<TemplateSessionUser>,
557 pub mnw_base_url: std::sync::Arc<str>,
558 }
559
560 // Moderation templates
561
562 /// Row in the active bans/mutes table.
563 pub struct BanListRow {
564 pub username: String,
565 pub display_name: Option<String>,
566 pub ban_type: String,
567 pub reason: Option<String>,
568 pub expires: Option<String>,
569 pub created: String,
570 pub banned_by: String,
571 }
572
573 /// Row in the mod log.
574 pub struct ModLogRow {
575 pub actor: String,
576 pub action: String,
577 pub target: Option<String>,
578 pub reason: Option<String>,
579 pub timestamp: String,
580 }
581
582 /// Pending flag for moderation page.
583 pub struct FlagViewRow {
584 pub flag_id: String,
585 pub post_id: String,
586 pub thread_id: String,
587 pub thread_title: String,
588 pub category_slug: String,
589 pub flagger_username: String,
590 pub reason: String,
591 pub detail: Option<String>,
592 pub created: String,
593 }
594
595 /// Community moderation page (mod/owner only).
596 #[derive(Template)]
597 #[template(path = "pages/moderation.html")]
598 pub struct ModerationTemplate {
599 pub csrf_token: CsrfTokenOption,
600 pub session_user: Option<TemplateSessionUser>,
601 pub mnw_base_url: std::sync::Arc<str>,
602 pub community_name: String,
603 pub community_slug: String,
604 pub bans: Vec<BanListRow>,
605 /// True when more active bans exist than the page renders (capped read).
606 pub bans_truncated: bool,
607 pub pending_flags: Vec<FlagViewRow>,
608 /// True when more pending flags exist than the page renders (capped read).
609 pub flags_truncated: bool,
610 pub is_owner: bool,
611 }
612
613 /// Soft-deleted thread awaiting restore or nothing.
614 pub struct DeletedThreadViewRow {
615 pub thread_id: String,
616 pub title: String,
617 pub category_slug: String,
618 pub author_username: String,
619 pub deleted: String,
620 /// Restoring also brings the opening post back, worth saying on the button
621 /// so the mod knows the scope of what they are undoing.
622 pub op_removed: bool,
623 }
624
625 /// Deleted-threads page (mod/owner only).
626 #[derive(Template)]
627 #[template(path = "pages/deleted_threads.html")]
628 pub struct DeletedThreadsTemplate {
629 pub csrf_token: CsrfTokenOption,
630 pub session_user: Option<TemplateSessionUser>,
631 pub mnw_base_url: std::sync::Arc<str>,
632 pub community_name: String,
633 pub community_slug: String,
634 pub threads: Vec<DeletedThreadViewRow>,
635 /// True when more deleted threads exist than the page renders (capped read).
636 pub threads_truncated: bool,
637 }
638
639 /// Mod log page (mod/owner only).
640 #[derive(Template)]
641 #[template(path = "pages/mod_log.html")]
642 pub struct ModLogTemplate {
643 pub csrf_token: CsrfTokenOption,
644 pub session_user: Option<TemplateSessionUser>,
645 pub mnw_base_url: std::sync::Arc<str>,
646 pub community_name: String,
647 pub community_slug: String,
648 pub entries: Vec<ModLogRow>,
649 pub pagination: Pagination,
650 }
651
652 /// One chat message, server-rendered for the first paint.
653 ///
654 /// The room is hydrated by the client island after load, but the initial
655 /// window is rendered here so the page is readable before any JS runs and
656 /// stays readable if the island fails.
657 pub struct ChatMessageRow {
658 pub id: i64,
659 pub author_id: String,
660 pub author_name: String,
661 pub avatar_url: Option<String>,
662 /// Already sanitized by docengine's chat preset at insert.
663 pub body_html: String,
664 /// Unix seconds; the client renders it in the viewer's locale.
665 pub created_at: i64,
666 }
667
668 /// The chat room page.
669 #[derive(Template)]
670 #[template(path = "pages/chat.html")]
671 pub struct ChatTemplate {
672 pub csrf_token: CsrfTokenOption,
673 pub session_user: Option<TemplateSessionUser>,
674 pub mnw_base_url: std::sync::Arc<str>,
675 pub community_name: String,
676 pub community_slug: String,
677 /// Frozen or archived: the backlog shows, the composer does not.
678 pub read_only: bool,
679 /// Whether to render the composer at all. One question answered once in the
680 /// handler rather than three conditions here.
681 pub can_send: bool,
682 pub is_moderator: bool,
683 /// The viewer's own account id, so the island can offer a delete control on
684 /// their own messages. `None` when logged out, and never load-bearing: the
685 /// delete handler re-derives authorship server-side, so this only decides
686 /// whether a button is drawn.
687 pub viewer_id: Option<String>,
688 pub max_message_len: usize,
689 pub messages: Vec<ChatMessageRow>,
690 /// Highest message id in the first paint; the island resumes from it.
691 pub cursor: i64,
692 }
693
694 // Admin templates
695
696 /// Row for communities in admin dashboard.
697 pub struct AdminCommunityViewRow {
698 pub id: String,
699 pub name: String,
700 pub slug: String,
701 pub is_suspended: bool,
702 pub suspension_reason: Option<String>,
703 }
704
705 /// Row for users in admin dashboard.
706 pub struct AdminUserViewRow {
707 pub id: String,
708 pub username: String,
709 pub display_name: Option<String>,
710 pub is_suspended: bool,
711 pub suspension_reason: Option<String>,
712 }
713
714 /// Platform admin dashboard.
715 #[derive(Template)]
716 #[template(path = "pages/admin.html")]
717 pub struct AdminDashboardTemplate {
718 pub csrf_token: CsrfTokenOption,
719 pub session_user: Option<TemplateSessionUser>,
720 pub mnw_base_url: std::sync::Arc<str>,
721 pub communities: Vec<AdminCommunityViewRow>,
722 /// True when more communities exist than the page caps at, so the template
723 /// can say so instead of silently dropping the tail.
724 pub communities_truncated: bool,
725 pub users: Vec<AdminUserViewRow>,
726 pub search_query: String,
727 }
728
729 #[cfg(test)]
730 mod base_layout_tests {
731 use super::Error404Template;
732 use askama::Template;
733
734 /// htmx reads `meta[name=htmx-config]` once, as the script runs. Below the
735 /// script tag the meta is inert, and htmx 4's swap-everything default puts a
736 /// rendered error page inside whatever the failed request targeted.
737 #[test]
738 fn htmx_config_precedes_the_bundle() {
739 let page = Error404Template {
740 csrf_token: None,
741 session_user: None,
742 mnw_base_url: std::sync::Arc::from("https://makenot.work"),
743 }
744 .render()
745 .expect("error page renders");
746
747 let meta = page
748 .find("name=\"htmx-config\"")
749 .expect("the config is stated");
750 let script = page.find("htmx.min.js").expect("htmx is linked");
751 assert!(meta < script);
752 assert!(page.contains("\"noSwap\""));
753 }
754 }
755