1use http::header::COOKIE;
4use topcoat::{
5 Result,
6 asset::Asset,
7 context::{Cx, try_request_context},
8 font::Font,
9 icon::icon,
10 router::Slot,
11 runtime::{Event, Signal, signal},
12 view::{Attributes, BoxView, Child, View, ViewExt, attributes, internal::ThenView, view},
13};
14
15use super::{Panel, state::current};
16use crate::{
17 navigation::NavigationItem,
18 notification::{LiveToast, live_toast, live_toaster, take_notification},
19 topcoat_compat::async_page,
20};
21
22fn sidebar_link(cx: &Cx, mut extra: Attributes) -> Attributes {
25 let mut attrs = crate::navigation::runtime_link(cx, "");
26 attrs.remove("href");
27 extra.extend(attrs);
28 extra
29}
30
31fn request_cookie(cx: &Cx, name: &str) -> Option<String> {
37 let header = try_request_context::<http::request::Parts>(cx)?
38 .headers
39 .get(COOKIE)?
40 .to_str()
41 .ok()?;
42 header.split(';').find_map(|part| {
43 let (key, value) = part.trim().split_once('=')?;
44 (key == name).then(|| value.to_string())
45 })
46}
47
48#[derive(Debug, Clone)]
51pub struct Brand {
52 pub name: String,
54 pub logo: Option<String>,
56}
57
58impl Brand {
59 pub fn new(name: impl Into<String>) -> Self {
62 Self {
63 name: name.into().trim().to_string(),
64 logo: None,
65 }
66 }
67
68 pub fn logo(mut self, logo: impl Into<String>) -> Self {
72 let logo = logo.into().trim().to_string();
73 if !logo.is_empty() {
74 self.logo = Some(logo);
75 }
76 self
77 }
78}
79
80#[derive(Debug, Clone, Copy)]
87pub(crate) struct ShellAssets {
88 pub(crate) stylesheet: Asset,
89 pub(crate) font: Font,
90}
91
92impl Panel {
93 fn tenant_switcher<'a>(
97 cx: &'a Cx,
98 tenants: &[crate::tenancy::Membership],
99 csrf: &str,
100 ) -> BoxView<'a> {
101 if tenants.len() < 2 {
102 return ().boxed();
103 }
104 let current = crate::auth::membership(cx);
105 let label = current.map_or_else(|| "Select a tenant".to_string(), |m| m.name.clone());
106 let current = current.map(|membership| membership.tenant);
107 let action = crate::auth::tenant_url(cx);
108 let choices: Vec<BoxView<'a>> = tenants
109 .iter()
110 .map(|membership| {
111 let selected = current == Some(membership.tenant);
112 let value = membership.tenant.to_string();
113 let name = membership.name.clone();
114 let action = action.clone();
115 let csrf = crate::csrf::field(cx, csrf);
116 view! {
117 cx =>
118 <form method="post" action=(action)>
119 (csrf)
120 <input
121 type="hidden"
122 name=(crate::auth::TENANT_FIELD)
123 value=(value)
124 >
125 <button
126 type="submit"
127 aria-current=(selected.then_some("true"))
128 class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
129 >
130 <span class="flex-1 truncate">(name)</span>
131 if selected {
132 icon(
133 data: tablo_ui::icons::CHECK,
134 attrs: attributes! { class="size-4" }
135 )
136 }
137 </button>
138 </form>
139 }
140 .boxed()
141 })
142 .collect();
143 view! {
144 cx =>
145 <details class="relative" data-tenant-switcher="">
146 <summary
147 class="flex cursor-pointer list-none items-center gap-1 rounded-md px-2 py-1 text-sm font-medium text-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"
148 >
149 <span class="max-w-40 truncate">(label)</span>
150 icon(
151 data: tablo_ui::icons::CHEVRON_DOWN,
152 attrs: attributes! { class="size-4 text-muted-foreground" }
153 )
154 </summary>
155 <div
156 class="absolute right-0 z-50 mt-1 min-w-48 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
157 >
158 for choice in choices {
159 (choice)
160 }
161 </div>
162 </details>
163 }
164 .boxed()
165 }
166
167 async fn theme_toggle(cx: &Cx) -> Result<BoxView<'_>> {
168 use tablo_ui::{ButtonSize, ButtonVariant, button};
169
170 Ok(view! {
173 cx =>
174 button(
175 variant: ButtonVariant::Ghost,
176 size: ButtonSize::Icon,
177 attrs: attributes! {
178 type="button"
179 aria-label="Toggle dark mode"
180 title="Toggle dark mode"
181 data-theme-toggle=""
182 },
183 icon(
184 data: tablo_ui::icons::SUN,
185 attrs: attributes! { class="dark:hidden" }
186 )
187 icon(
188 data: tablo_ui::icons::MOON,
189 attrs: attributes! { class="hidden dark:block" }
190 )
191 )
192 }
193 .boxed())
194 }
195
196 pub(crate) async fn render_brand(cx: &Cx) -> Result<BoxView<'_>> {
197 let (name, logo) = if let Some(brand) = current(cx).and_then(|panel| panel.brand.as_ref()) {
198 (brand.name.clone(), brand.logo.clone())
199 } else {
200 ("Tablo".to_string(), None)
201 };
202 let mark: BoxView<'_> = match logo {
205 Some(logo_url) => {
206 let alt = name.clone();
207 view! {
208 cx =>
209 <img
210 src=(logo_url)
211 alt=(alt)
212 width="28"
213 height="28"
214 class="size-7 shrink-0 rounded-md"
215 >
216 }
217 .boxed()
218 }
219 None => {
220 let initial = name.chars().next().map(String::from).unwrap_or_default();
221 view! {
222 cx =>
223 <span
224 aria-hidden="true"
225 class="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary text-sm font-semibold text-primary-foreground"
226 >
227 (initial)
228 </span>
229 }
230 .boxed()
231 }
232 };
233 Ok(view! {
234 cx =>
235 <div class="flex min-w-0 items-center gap-2 font-semibold text-foreground">
236 (mark)
237 <span class="truncate">(name)</span>
238 </div>
239 }
240 .boxed())
241 }
242
243 async fn sidebar_navigation<'a>(
244 cx: &'a Cx,
245 nav_items: &[NavigationItem],
246 current_path: &str,
247 mobile_open: Signal<bool>,
248 ) -> Result<BoxView<'a>> {
249 use tablo_ui::{
250 sidebar_group, sidebar_group_content, sidebar_group_label, sidebar_menu,
251 sidebar_menu_button, sidebar_menu_item,
252 };
253 let mut nav_items = nav_items.to_vec();
254 nav_items.sort_by_key(|item| item.order);
258 debug_assert!(
263 nav_items.iter().all(|item| item.url().is_some()),
264 "navigation items are resolved by the Panel that owns them"
265 );
266 let active = nav_items
270 .iter()
271 .enumerate()
272 .filter(|(_, item)| item.is_current_path(current_path))
273 .min_by_key(|(_, item)| std::cmp::Reverse(item.url().map_or(0, str::len)))
274 .map(|(index, _)| index);
275
276 Ok(view! {
277 cx =>
278 sidebar_group(
279 sidebar_group_label("Navigation")
280 sidebar_group_content(
281 sidebar_menu(
282 for (index, item) in nav_items.iter().enumerate() {
283 let is_active = active == Some(index);
284 let attrs = sidebar_link(
285 cx,
286 attributes! {
287 @click=$(|_e: Event| mobile_open.set(false))
290 },
291 );
292 sidebar_menu_item(
293 sidebar_menu_button(
294 active: is_active,
295 href: item.url(),
296 tooltip: Some(item.label.as_str()),
297 attrs: attrs,
298 if let Some(data) = item.icon.clone() {
299 icon(data: data)
300 }
301 <span>(item.label.clone())</span>
302 )
303 )
304 }
305 )
306 )
307 )
308 }
309 .boxed())
310 }
311
312 fn sidebar_starts_open(cx: &Cx) -> bool {
319 request_cookie(cx, "sidebar_state").as_deref() != Some("collapsed")
320 }
321
322 pub async fn render_shell<'a>(
324 cx: &'a Cx,
325 nav_items: &[NavigationItem],
326 current_path: &str,
327 slot: Child<'a>,
328 extra_class: Option<String>,
329 ) -> Result<BoxView<'a>> {
330 let nav_items = nav_items.to_vec();
334 let current_path = current_path.to_string();
335 Ok(async_page(async move {
336 Self::render_shell_body(cx, &nav_items, ¤t_path, slot, extra_class).await
337 }))
338 }
339
340 async fn render_shell_body<'a>(
341 cx: &'a Cx,
342 nav_items: &[NavigationItem],
343 current_path: &str,
344 slot: Child<'a>,
345 extra_class: Option<String>,
346 ) -> Result<BoxView<'a>> {
347 use tablo_ui::{
348 SeparatorOrientation, SidebarCollapsible, separator, sidebar, sidebar_content,
349 sidebar_header, sidebar_inset, sidebar_provider, sidebar_trigger,
350 };
351
352 let sidebar_open = signal(cx, || Self::sidebar_starts_open(cx));
353 let mobile_open = signal(cx, || false);
354 let outer_class = extra_class.clone().unwrap_or_default();
355 let header_title = brand_name(cx);
356 let navigation =
359 Self::sidebar_navigation(cx, nav_items, current_path, mobile_open.clone()).await?;
360 let sidebar_brand = Self::render_brand(cx).await?;
361 let theme_toggle = Self::theme_toggle(cx).await?;
362 let account_view: BoxView<'_> = match crate::auth::signed(cx) {
366 Some(signed) => {
367 let csrf = crate::csrf::ensure_token(cx);
368 let logout = crate::auth::logout_url(cx);
369 let name = signed.user.display_name().to_string();
370 let initial = name.chars().next().map(String::from).unwrap_or_default();
371 let switcher = Self::tenant_switcher(cx, signed.user.tenants(), &csrf);
372 view! {
373 cx =>
374 <div class="flex items-center gap-2">
375 (switcher)
376 <span
377 aria-hidden="true"
378 class="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground"
379 >
380 (initial)
381 </span>
382 <span class="max-sm:hidden text-sm font-medium text-foreground">
383 (name)
384 </span>
385 <form method="post" action=(logout)>
386 (crate::csrf::field(cx, &csrf))
387 tablo_ui::button(
388 variant: tablo_ui::ButtonVariant::Ghost,
389 size: tablo_ui::ButtonSize::Icon,
390 attrs: attributes! { type="submit" aria-label="Sign out" title="Sign out" },
391 icon(data: tablo_ui::icons::LOG_OUT)
392 )
393 </form>
394 </div>
395 }
396 .boxed()
397 }
398 None => view! { cx => <span></span> }.boxed(),
399 };
400 let notification_view: BoxView<'_> = match take_notification(cx) {
401 Some(notification) => {
402 crate::notification::render_notification(cx, notification, Default::default())
403 .await?
404 }
405 None => ().boxed(),
409 };
410 let LiveToast {
413 status: toast_status,
414 title: toast_title,
415 description: toast_description,
416 serial: toast_serial,
417 } = live_toast(cx);
418
419 Ok(view! {
420 cx =>
421 sidebar_provider(
422 attrs: attributes! { class=(outer_class) },
423 sidebar(
429 open: $(sidebar_open.get()),
430 mobile_open: $(mobile_open.get()),
431 collapsible: SidebarCollapsible::Offcanvas,
432 sheet_attrs: attributes! {
433 id="mobile-sidebar-sheet"
434 aria-label="Navigation"
435 @keydown=$(|e: Event| {
436 if e.key == "Escape" {
437 mobile_open.set(false);
438 }
439 })
440 @click=$(|e: Event| {
441 if e.target.id == "mobile-sidebar-sheet" {
442 mobile_open.set(false);
443 }
444 })
445 },
446 sidebar_header(
447 <div class="flex items-center gap-2 px-2">
448 (sidebar_brand)
449 tablo_ui::button(
454 variant: tablo_ui::ButtonVariant::Ghost,
455 size: tablo_ui::ButtonSize::Icon,
456 attrs: attributes! {
457 type="button"
458 class="md:hidden ml-auto"
459 aria-label="Close sidebar"
460 @click=$(|_e: Event| mobile_open.set(false))
461 },
462 icon(data: tablo_ui::icons::X)
463 )
464 </div>
465 )
466 sidebar_content((navigation))
467 )
468 sidebar_inset(
469 sidebar_header(
470 sidebar_trigger(
474 open: $(sidebar_open.get()),
475 attrs: attributes! {
476 class="max-md:hidden"
477 aria-controls="mobile-sidebar-sheet"
478 @click=$(|_e: Event| sidebar_open.toggle())
479 }
480 )
481 sidebar_trigger(
482 open: $(mobile_open.get()),
483 attrs: attributes! {
484 class="md:hidden"
485 aria-controls="mobile-sidebar-sheet"
486 @click=$(|_e: Event| mobile_open.toggle())
487 }
488 )
489 <div class="md:hidden font-semibold text-foreground">
493 (header_title)
494 </div>
495 <div class="ml-auto">(theme_toggle)</div>
498 separator(orientation: SeparatorOrientation::Vertical)
499 (account_view)
500 )
501 <div class="flex flex-1 flex-col">(slot)</div>
507 )
508 tablo_ui::toaster(
514 (notification_view)
515 live_toaster(
516 status: $(toast_status),
517 title: $(toast_title),
518 description: $(toast_description),
519 serial: $(toast_serial)
520 )
521 )
522 ) }
524 .boxed())
525 }
526
527 pub fn layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> BoxView<'a> {
538 Box::pin(ThenView::new(Self::render_layout_shell(cx, slot)))
539 }
540
541 async fn render_layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> Result<BoxView<'a>> {
542 use topcoat::router::request::uri;
543 let path = uri(cx).path().to_string();
544 let nav_items = current(cx)
546 .map(|panel| panel.nav_items.clone())
547 .filter(|items| !items.is_empty())
548 .unwrap_or_else(|| vec![NavigationItem::at("Home", super::gate::panel_prefix(cx))]);
549 let shell = Self::render_shell(cx, &nav_items, &path, slot, None).await?;
550 Self::render_document(cx, brand_name(cx), shell).await
551 }
552
553 pub async fn document<'a>(
555 cx: &'a Cx,
556 title: impl Into<String>,
557 body: impl View + 'a,
558 ) -> Result<BoxView<'a>> {
559 Self::render_document(cx, title.into(), body.boxed()).await
560 }
561
562 pub(crate) async fn render_document<'a>(
564 cx: &'a Cx,
565 title: String,
566 body: BoxView<'a>,
567 ) -> Result<BoxView<'a>> {
568 let default_dark = current(cx).is_some_and(|panel| panel.dark_mode);
571 let head: BoxView<'_> = match current(cx).and_then(|panel| panel.shell_assets) {
572 Some(ShellAssets { stylesheet, font }) => view! {
573 cx =>
574 topcoat::dev::script()
575 tablo_ui::theme_init_script(default_dark: default_dark)
576 topcoat::runtime::script()
577 topcoat::font::link(font: font)
578 <link rel="stylesheet" href=(stylesheet)>
579 <script src=(tablo_ui::SIDEBAR_JS) defer=""></script>
580 <script src=(tablo_ui::THEME_JS) defer=""></script>
581 <script src=(tablo_ui::DIALOG_JS) defer=""></script>
582 <script src=(tablo_ui::WIRE_JS) defer=""></script>
583 <script src=(tablo_ui::BULK_JS) defer=""></script>
584 <script src=(tablo_ui::FILTERS_JS) defer=""></script>
585 <script src=(tablo_ui::LIVE_SEARCH_JS) defer=""></script>
586 <script src=(tablo_ui::SELECTS_JS) defer=""></script>
587 <script src=(tablo_ui::VARIANT_JS) defer=""></script>
588 <script src=(tablo_ui::NOTIFICATION_JS) defer=""></script>
589 <script src=(tablo_ui::MUTATION_SUBMIT_JS) defer=""></script>
590 }
591 .boxed(),
592 None => view! {
593 cx =>
594 topcoat::dev::script()
595 tablo_ui::theme_init_script(default_dark: default_dark)
596 }
597 .boxed(),
598 };
599 let dark = match request_cookie(cx, "theme").as_deref() {
600 Some("dark") => true,
601 Some("light") => false,
602 _ => default_dark,
603 };
604 let html_class = dark.then_some("dark");
605 Ok(view! {
606 cx =>
607 <!DOCTYPE html>
608 <html class=(html_class)>
609 <head>
610 <title>(title)</title>
611 (head)
612 </head>
613 <body>(body)</body>
614 </html>
615 }
616 .boxed())
617 }
618}
619
620fn brand_name(cx: &Cx) -> String {
622 current(cx)
623 .and_then(|panel| panel.brand.as_ref())
624 .map_or_else(|| "Tablo".to_string(), |brand| brand.name.clone())
625}
626
627#[cfg(test)]
628mod tests;