1use crate::{
2 assets,
3 components::{Avatar, HtmlAttr, MenuItem, MenuItemKind},
4 html,
5};
6use askama::Template;
7
8pub const DEFAULT_PROFILE_LOGOUT_CONFIRM: &str = "Log out of this session?";
9
10pub const DEFAULT_PROFILE_LOGOUT_ATTRS: [HtmlAttr<'static>; 2] = [
11 HtmlAttr::hx_post("/logout"),
12 HtmlAttr::hx_confirm(DEFAULT_PROFILE_LOGOUT_CONFIRM),
13];
14
15pub const DEFAULT_PROFILE_MENU_ITEMS: [MenuItem<'static>; 3] = [
16 MenuItem::link("Settings", "/settings"),
17 MenuItem::separator(),
18 MenuItem::button("Logout")
19 .danger()
20 .with_attrs(&DEFAULT_PROFILE_LOGOUT_ATTRS),
21];
22
23#[derive(Debug, Template)]
24#[non_exhaustive]
25#[template(path = "layouts/sidebar_profile.html")]
26pub struct SidebarProfile<'a> {
27 pub name: Option<&'a str>,
28 pub email: Option<&'a str>,
29 pub avatar: Option<Avatar<'a>>,
30 pub menu_items: &'a [MenuItem<'a>],
31}
32
33impl<'a> SidebarProfile<'a> {
34 pub const fn new() -> Self {
35 Self {
36 name: None,
37 email: None,
38 avatar: None,
39 menu_items: &DEFAULT_PROFILE_MENU_ITEMS,
40 }
41 }
42
43 pub const fn with_name(mut self, name: &'a str) -> Self {
44 self.name = Some(name);
45 self
46 }
47
48 pub const fn with_email(mut self, email: &'a str) -> Self {
49 self.email = Some(email);
50 self
51 }
52
53 pub const fn with_avatar(mut self, avatar: Avatar<'a>) -> Self {
54 self.avatar = Some(avatar);
55 self
56 }
57
58 pub const fn with_menu_items(mut self, menu_items: &'a [MenuItem<'a>]) -> Self {
59 self.menu_items = menu_items;
60 self
61 }
62
63 pub fn display_label(&self) -> &str {
64 self.name.or(self.email).unwrap_or("Profile")
65 }
66
67 pub const fn secondary_email(&self) -> Option<&'a str> {
68 if self.name.is_some() {
69 self.email
70 } else {
71 None
72 }
73 }
74}
75
76impl<'a> Default for SidebarProfile<'a> {
77 fn default() -> Self {
78 Self::new()
79 }
80}
81
82impl<'a> askama::filters::HtmlSafe for SidebarProfile<'a> {}
83
84#[derive(Debug, Template)]
85#[template(path = "layouts/app_shell.html")]
86pub struct AppShell<'a> {
87 pub title: &'a str,
88 pub app_name: &'a str,
89 pub mode: &'a str,
90 pub density_class: &'a str,
91 pub asset_base_path: &'a str,
92 pub nav_html: &'a str,
93 pub actions_html: &'a str,
94 pub content_html: &'a str,
95 pub profile: Option<SidebarProfile<'a>>,
96 pub status_left: &'a str,
97 pub status_right: &'a str,
98}
99
100impl<'a> AppShell<'a> {
101 pub const fn new(title: &'a str, app_name: &'a str, content_html: &'a str) -> Self {
102 Self {
103 title,
104 app_name,
105 mode: "dark",
106 density_class: "density-dense",
107 asset_base_path: assets::DEFAULT_BASE_PATH,
108 nav_html: "",
109 actions_html: "",
110 content_html,
111 profile: None,
112 status_left: app_name,
113 status_right: "",
114 }
115 }
116
117 pub const fn with_mode(mut self, mode: &'a str) -> Self {
118 self.mode = mode;
119 self
120 }
121
122 pub const fn light(self) -> Self {
123 self.with_mode("light")
124 }
125
126 pub const fn dark(self) -> Self {
127 self.with_mode("dark")
128 }
129
130 pub const fn dense(mut self) -> Self {
131 self.density_class = "density-dense";
132 self
133 }
134
135 pub const fn default_density(mut self) -> Self {
136 self.density_class = "";
137 self
138 }
139
140 pub const fn with_asset_base_path(mut self, asset_base_path: &'a str) -> Self {
141 self.asset_base_path = asset_base_path;
142 self
143 }
144
145 pub const fn with_nav(mut self, nav_html: &'a str) -> Self {
146 self.nav_html = nav_html;
147 self
148 }
149
150 pub const fn with_actions(mut self, actions_html: &'a str) -> Self {
151 self.actions_html = actions_html;
152 self
153 }
154
155 pub const fn with_profile(mut self, profile: SidebarProfile<'a>) -> Self {
156 self.profile = Some(profile);
157 self
158 }
159
160 pub const fn with_status(mut self, status_left: &'a str, status_right: &'a str) -> Self {
161 self.status_left = status_left;
162 self.status_right = status_right;
163 self
164 }
165
166 pub fn stylesheet_link(&self) -> String {
167 html::stylesheet_link(self.asset_base_path)
168 }
169
170 pub fn htmx_script_link(&self) -> String {
171 html::htmx_script_link(self.asset_base_path)
172 }
173
174 pub fn script_link(&self) -> String {
175 html::script_link(self.asset_base_path)
176 }
177}
178
179impl<'a> askama::filters::HtmlSafe for AppShell<'a> {}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::components::{HtmlAttr, MenuItem};
185
186 #[test]
187 fn app_shell_builders_render_variants() {
188 let html = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
189 .light()
190 .default_density()
191 .with_nav(r#"<a class="wf-nav-item" href="/">Home</a>"#)
192 .with_actions(r#"<button class="wf-btn">Save</button>"#)
193 .with_status("Ready", "v0.1")
194 .render()
195 .unwrap();
196
197 assert!(html.contains(r#"data-mode="light""#));
198 assert!(html.contains(r#"class="wf-app ""#));
199 assert!(html.contains(r#"<a class="wf-nav-item" href="/">Home</a>"#));
200 assert!(html.contains(r#"<button class="wf-btn">Save</button>"#));
201 assert!(html.contains(">Ready<"));
202 assert!(html.contains(">v0.1<"));
203 }
204
205 #[test]
206 fn app_shell_omits_sidebar_profile_until_supplied() {
207 let html = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
208 .render()
209 .unwrap();
210
211 assert!(!html.contains("wf-sidebar-profile"));
212 assert!(!html.contains("hx-post=\"/logout\""));
213 assert!(!html.contains("Log out of this session?"));
214 }
215
216 #[test]
217 fn app_shell_renders_default_sidebar_profile_menu_after_nav() {
218 let html = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
219 .with_nav(r#"<a class="wf-nav-item" href="/">Home</a>"#)
220 .with_profile(
221 SidebarProfile::new()
222 .with_name("Sandeep Nambiar")
223 .with_email("sandeep@wavefunk.test"),
224 )
225 .render()
226 .unwrap();
227
228 let nav_position = html.find(r#"id="app-nav""#).unwrap();
229 let profile_position = html.find("wf-sidebar-profile").unwrap();
230
231 assert!(profile_position > nav_position);
232 assert!(html.contains(">Sandeep Nambiar<"));
233 assert!(html.contains(">sandeep@wavefunk.test<"));
234 assert!(html.contains(r#"href="/settings""#));
235 assert!(html.contains(">Settings<"));
236 assert!(html.contains(r#"hx-post="/logout""#));
237 assert!(html.contains(r#"hx-confirm="Log out of this session?""#));
238 assert!(html.contains(">Logout<"));
239 }
240
241 #[test]
242 fn sidebar_profile_label_falls_back_and_menu_items_can_be_overridden() {
243 let logout_attrs = [
244 HtmlAttr::hx_post("/sessions/current/delete"),
245 HtmlAttr::hx_confirm("Really log out?"),
246 ];
247 let custom_menu = [
248 MenuItem::link("Account", "/account"),
249 MenuItem::button("Sign out")
250 .danger()
251 .with_attrs(&logout_attrs),
252 ];
253
254 let email_only = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
255 .with_profile(
256 SidebarProfile::new()
257 .with_email("team@example.test")
258 .with_menu_items(&custom_menu),
259 )
260 .render()
261 .unwrap();
262
263 assert_eq!(email_only.matches("team@example.test").count(), 1);
264 assert!(email_only.contains(">Account<"));
265 assert!(email_only.contains(r#"href="/account""#));
266 assert!(email_only.contains(">Sign out<"));
267 assert!(email_only.contains(r#"hx-post="/sessions/current/delete""#));
268 assert!(email_only.contains(r#"hx-confirm="Really log out?""#));
269 assert!(!email_only.contains(r#"href="/settings""#));
270 assert!(!email_only.contains(r#"hx-post="/logout""#));
271
272 let anonymous = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
273 .with_profile(SidebarProfile::new())
274 .render()
275 .unwrap();
276
277 assert!(anonymous.contains(">Profile<"));
278 }
279}