tablo_core/panel/shell.rs
1//! Renders the shell framing every admin page.
2
3use 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
22/// `extra` plus the attribute that sends a sidebar link through runtime
23/// navigation. The menu button writes the `href` itself, so this carries none.
24fn 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
31/// The value of the request's `name` cookie.
32///
33/// Parsed from the raw `Cookie` header on purpose: `topcoat::cookie::cookies`
34/// panics when the cookie router layer is absent (tests, minimal routers), and
35/// the shell must render everywhere.
36fn 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/// Branding for the panel's shell: the sidebar header, the login card, and the
49/// topbar below `md`, where the sidebar folds into a sheet.
50#[derive(Debug, Clone)]
51pub struct Brand {
52 /// Display name (e.g. `"Acme"`).
53 pub name: String,
54 /// Optional logo URL (e.g. `"/logo.svg"`). Rendered as an `<img>` when present.
55 pub logo: Option<String>,
56}
57
58impl Brand {
59 /// Create a brand with the given name (surrounding whitespace is
60 /// trimmed so `" Acme "` cannot break the `flex h-16` header).
61 pub fn new(name: impl Into<String>) -> Self {
62 Self {
63 name: name.into().trim().to_string(),
64 logo: None,
65 }
66 }
67
68 /// Attach a logo URL (blank values are ignored so an empty
69 /// `logo("")` falls back to the name-only render instead of a
70 /// broken-image icon).
71 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/// The application-owned assets used by [`Panel::layout_shell`].
81///
82/// Tailwind's generated stylesheet is necessarily a call-site asset because
83/// every application scans a different source tree. The Panel therefore takes
84/// the generated stylesheet and the application's chosen font as values while
85/// still owning the document markup that links them.
86#[derive(Debug, Clone, Copy)]
87pub(crate) struct ShellAssets {
88 pub(crate) stylesheet: Asset,
89 pub(crate) font: Font,
90}
91
92impl Panel {
93 /// The top bar's tenant switcher: a disclosure listing the user's
94 /// tenants, each a form that posts to the tenant-switch route. A user
95 /// with fewer than two tenants has nothing to switch, and gets nothing.
96 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 // Both icons ship and the `dark` class on `<html>` picks the visible
171 // one, so the control needs no script of its own to show the theme.
172 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 // Without a logo the brand's initial stands in, on the primary token,
203 // so the header keeps its mark.
204 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 // Stable order: explicit `order` first, declaration order
255 // breaking ties — a resource's `navigation()` override interleaves by
256 // setting it.
257 nav_items.sort_by_key(|item| item.order);
258 // A Panel resolves every item it owns (`Panel::resource`, `page`,
259 // `home`); one that
260 // reaches the sidebar unresolved has no URL to render, which is a
261 // framework bug rather than user error.
262 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 // One active entry: a home entry at the bare prefix matches every path
267 // under it, so the longest matching path wins, and the first such entry
268 // among any that share it.
269 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.path().map_or(0, str::len)))
274 .map(|(index, _)| index);
275
276 // The ungrouped entries first, under the default label, then one labelled group per
277 // `group`, in the order of its first entry.
278 let mut sections: Vec<(String, Vec<(usize, NavigationItem)>)> =
279 vec![("Navigation".to_string(), Vec::new())];
280 for (index, item) in nav_items.into_iter().enumerate() {
281 let at = match &item.group {
282 None => Some(0),
283 Some(group) => sections
284 .iter()
285 .skip(1)
286 .position(|(label, _)| label == group)
287 .map(|at| at + 1),
288 };
289 match at {
290 Some(at) => sections[at].1.push((index, item)),
291 None => {
292 sections.push((item.group.clone().unwrap_or_default(), vec![(index, item)]))
293 }
294 }
295 }
296 if sections.len() > 1 && sections[0].1.is_empty() {
297 sections.remove(0);
298 }
299
300 Ok(view! {
301 cx =>
302 for (label, entries) in sections {
303 sidebar_group(
304 sidebar_group_label((label))
305 sidebar_group_content(
306 sidebar_menu(
307 for (index, item) in entries {
308 let is_active = active == Some(index);
309 let attrs = sidebar_link(
310 cx,
311 attributes! {
312 // Tapping a link in the mobile sheet closes
313 // it; on desktop the navigation is the effect.
314 @click=$(|_e: Event| mobile_open.set(false))
315 },
316 );
317 sidebar_menu_item(
318 sidebar_menu_button(
319 active: is_active,
320 href: item.url(),
321 tooltip: Some(item.label.as_str()),
322 attrs: attrs,
323 if let Some(data) = item.icon.clone() {
324 icon(data: data)
325 }
326 <span>(item.label.clone())</span>
327 )
328 )
329 }
330 )
331 )
332 )
333 }
334 }
335 .boxed())
336 }
337
338 /// Whether the persisted `sidebar_state` cookie asks for an expanded
339 /// desktop sidebar (default: expanded).
340 ///
341 /// The value only seeds the runtime
342 /// signal's initial `data-state`; after hydration the browser owns the
343 /// state, and `assets/sidebar.js` mirrors changes back to the cookie.
344 fn sidebar_starts_open(cx: &Cx) -> bool {
345 request_cookie(cx, "sidebar_state").as_deref() != Some("collapsed")
346 }
347
348 /// Renders the shell around `slot` with an optional outer class.
349 pub async fn render_shell<'a>(
350 cx: &'a Cx,
351 nav_items: &[NavigationItem],
352 current_path: &str,
353 slot: Child<'a>,
354 extra_class: Option<String>,
355 ) -> Result<BoxView<'a>> {
356 // A hoisting body: the sidebar signals are declared while this view
357 // resolves, and a page re-run resumes them from the client. The owned
358 // copies pin the caller's navigation to the lazy body's lifetime.
359 let nav_items = nav_items.to_vec();
360 let current_path = current_path.to_string();
361 Ok(async_page(async move {
362 Self::render_shell_body(cx, &nav_items, ¤t_path, slot, extra_class).await
363 }))
364 }
365
366 async fn render_shell_body<'a>(
367 cx: &'a Cx,
368 nav_items: &[NavigationItem],
369 current_path: &str,
370 slot: Child<'a>,
371 extra_class: Option<String>,
372 ) -> Result<BoxView<'a>> {
373 use tablo_ui::{
374 SeparatorOrientation, SidebarCollapsible, separator, sidebar, sidebar_content,
375 sidebar_header, sidebar_inset, sidebar_provider, sidebar_trigger,
376 };
377
378 let sidebar_open = signal(cx, || Self::sidebar_starts_open(cx));
379 let mobile_open = signal(cx, || false);
380 let outer_class = extra_class.clone().unwrap_or_default();
381 let header_title = brand_name(cx);
382 // One navigation tree: the upstream sidebar renders its children once
383 // and shares them between the desktop panel and the mobile sheet.
384 let navigation =
385 Self::sidebar_navigation(cx, nav_items, current_path, mobile_open.clone()).await?;
386 let sidebar_brand = Self::render_brand(cx).await?;
387 let theme_toggle = Self::theme_toggle(cx).await?;
388 // Signed-in identity + logout control, present only with a session
389 // (ADR-0013). `ensure_token` runs before any streaming starts so the
390 // logout form always carries a matching CSRF pair.
391 let account_view: BoxView<'_> = match crate::auth::signed(cx) {
392 Some(signed) => {
393 let csrf = crate::csrf::ensure_token(cx);
394 let logout = crate::auth::logout_url(cx);
395 let name = signed.user.display_name().to_string();
396 let initial = name.chars().next().map(String::from).unwrap_or_default();
397 let switcher = Self::tenant_switcher(cx, signed.user.tenants(), &csrf);
398 view! {
399 cx =>
400 <div class="flex items-center gap-2">
401 (switcher)
402 <span
403 aria-hidden="true"
404 class="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground"
405 >
406 (initial)
407 </span>
408 <span class="max-sm:hidden text-sm font-medium text-foreground">
409 (name)
410 </span>
411 <form method="post" action=(logout)>
412 (crate::csrf::field(cx, &csrf))
413 tablo_ui::button(
414 variant: tablo_ui::ButtonVariant::Ghost,
415 size: tablo_ui::ButtonSize::Icon,
416 attrs: attributes! { type="submit" aria-label="Sign out" title="Sign out" },
417 icon(data: tablo_ui::icons::LOG_OUT)
418 )
419 </form>
420 </div>
421 }
422 .boxed()
423 }
424 None => view! { cx => <span></span> }.boxed(),
425 };
426 let notification_view: BoxView<'_> = match take_notification(cx) {
427 Some(notification) => {
428 crate::notification::render_notification(cx, notification, Default::default())
429 .await?
430 }
431 // No `<span>` placeholder: the toaster renders an `<ol>`,
432 // which permits only `li`/`script`/`template` children — the empty
433 // view renders nothing.
434 None => ().boxed(),
435 };
436 // The page owns the live-toast signals; resolve the same handles here
437 // (same helper, same request identity) and hand them to the shard.
438 let LiveToast {
439 status: toast_status,
440 title: toast_title,
441 description: toast_description,
442 serial: toast_serial,
443 } = live_toast(cx);
444
445 Ok(view! {
446 cx =>
447 sidebar_provider(
448 attrs: attributes! { class=(outer_class) },
449 // `sidebar_rail` is intentionally not rendered: the header
450 // `sidebar_trigger` is the explicit toggle, and the rail's
451 // edge hit-area reads as stray chrome as a primary toggle.
452 // Keep the component available (`tablo_ui::sidebar_rail`)
453 // for opt-in `variant=inset`/`floating` layouts.
454 sidebar(
455 open: $(sidebar_open.get()),
456 mobile_open: $(mobile_open.get()),
457 collapsible: SidebarCollapsible::Offcanvas,
458 sheet_attrs: attributes! {
459 id="mobile-sidebar-sheet"
460 aria-label="Navigation"
461 @keydown=$(|e: Event| {
462 if e.key == "Escape" {
463 mobile_open.set(false);
464 }
465 })
466 @click=$(|e: Event| {
467 if e.target.id == "mobile-sidebar-sheet" {
468 mobile_open.set(false);
469 }
470 })
471 },
472 sidebar_header(
473 <div class="flex items-center gap-2 px-2">
474 (sidebar_brand)
475 // Below `md` the sheet covers the inset header,
476 // so the sheet's own header carries the close
477 // control (upstream `examples/ui`); the mobile
478 // trigger in the inset header is behind the veil.
479 tablo_ui::button(
480 variant: tablo_ui::ButtonVariant::Ghost,
481 size: tablo_ui::ButtonSize::Icon,
482 attrs: attributes! {
483 type="button"
484 class="md:hidden ml-auto"
485 aria-label="Close sidebar"
486 @click=$(|_e: Event| mobile_open.set(false))
487 },
488 icon(data: tablo_ui::icons::X)
489 )
490 </div>
491 )
492 sidebar_content((navigation))
493 )
494 sidebar_inset(
495 sidebar_header(
496 // The desktop trigger collapses the rail; below md the
497 // mobile trigger opens the sheet instead (shadcn
498 // SidebarTrigger pair, upstream `examples/ui`).
499 sidebar_trigger(
500 open: $(sidebar_open.get()),
501 attrs: attributes! {
502 class="max-md:hidden"
503 aria-controls="mobile-sidebar-sheet"
504 @click=$(|_e: Event| sidebar_open.toggle())
505 }
506 )
507 sidebar_trigger(
508 open: $(mobile_open.get()),
509 attrs: attributes! {
510 class="md:hidden"
511 aria-controls="mobile-sidebar-sheet"
512 @click=$(|_e: Event| mobile_open.toggle())
513 }
514 )
515 // The sidebar carries the brand on desktop; below md
516 // it hides in the sheet, so the topbar names the
517 // panel there.
518 <div class="md:hidden font-semibold text-foreground">
519 (header_title)
520 </div>
521 // The separator stays a direct child of the topbar,
522 // where `sidebar_inset` sizes a vertical rule.
523 <div class="ml-auto">(theme_toggle)</div>
524 separator(orientation: SeparatorOrientation::Vertical)
525 (account_view)
526 )
527 // `sidebar_inset` is the document's one `<main>`; a second
528 // nested landmark is invalid and confuses landmark
529 // navigation (upstream `examples/ui` uses a plain div).
530 // The page container (`tablo_ui::page`) owns the width
531 // and the padding.
532 <div class="flex flex-1 flex-col">(slot)</div>
533 )
534 // Toast stack — the shadcn/Sonner surface, fixed bottom-right
535 // and a polite live region so streamed swaps are announced.
536 // `live_toaster` is the page-owned
537 // in-place transport; the flash cookie's toast
538 // rides beside it.
539 tablo_ui::toaster(
540 (notification_view)
541 live_toaster(
542 status: $(toast_status),
543 title: $(toast_title),
544 description: $(toast_description),
545 serial: $(toast_serial)
546 )
547 )
548 ) // Scripts are owned by the document (layout_shell).
549 }
550 .boxed())
551 }
552
553 /// The panel shell around a page, as a complete HTML document: the layout
554 /// every panel registers at its prefix unless [`Panel::layout`] replaces
555 /// it, and what a replacement calls to keep the shell around its own
556 /// markup.
557 ///
558 /// The stylesheet and font are supplied to the Panel builder with
559 /// [`Self::shell_assets`]. A Panel without those values remains renderable
560 /// for tests and custom document owners, but does not pretend that a CSS
561 /// bundle exists. Errors from the page slot propagate unchanged when the
562 /// document view is resolved.
563 pub fn layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> BoxView<'a> {
564 Box::pin(ThenView::new(Self::render_layout_shell(cx, slot)))
565 }
566
567 async fn render_layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> Result<BoxView<'a>> {
568 use topcoat::router::request::uri;
569 let path = uri(cx).path().to_string();
570 // The panel's sidebar, or a single Home entry outside any panel.
571 let nav_items = current(cx)
572 .map(|panel| {
573 panel
574 .nav_items
575 .iter()
576 .filter(|entry| (entry.visible)(cx))
577 .map(|entry| entry.item.clone())
578 .collect::<Vec<_>>()
579 })
580 .filter(|items| !items.is_empty())
581 .unwrap_or_else(|| vec![NavigationItem::at("Home", super::gate::panel_prefix(cx))]);
582 let shell = Self::render_shell(cx, &nav_items, &path, slot, None).await?;
583 Self::render_document(cx, brand_name(cx), shell).await
584 }
585
586 /// Renders a complete HTML document around a page outside the panel prefix.
587 pub async fn document<'a>(
588 cx: &'a Cx,
589 title: impl Into<String>,
590 body: impl View + 'a,
591 ) -> Result<BoxView<'a>> {
592 Self::render_document(cx, title.into(), body.boxed()).await
593 }
594
595 /// Renders the HTML document with assets and the dark-mode class.
596 pub(crate) async fn render_document<'a>(
597 cx: &'a Cx,
598 title: String,
599 body: BoxView<'a>,
600 ) -> Result<BoxView<'a>> {
601 // The panel's default: the `<html class>` a first-time visitor gets,
602 // and the fallback the blocking script uses when nothing is stored.
603 let default_dark = current(cx).is_some_and(|panel| panel.dark_mode);
604 let head: BoxView<'_> = match current(cx).and_then(|panel| panel.shell_assets) {
605 Some(ShellAssets { stylesheet, font }) => view! {
606 cx =>
607 topcoat::dev::script()
608 tablo_ui::theme_init_script(default_dark: default_dark)
609 topcoat::runtime::script()
610 topcoat::font::link(font: font)
611 <link rel="stylesheet" href=(stylesheet)>
612 // Modules, so each script's top-level names stay its own: as classic scripts they
613 // share one global scope, where a later script's `partsOf` replaces an earlier one's.
614 // A module is deferred like these were.
615 <script src=(tablo_ui::SIDEBAR_JS) type="module"></script>
616 <script src=(tablo_ui::THEME_JS) type="module"></script>
617 <script src=(tablo_ui::SELECTS_JS) type="module"></script>
618 <script src=(tablo_ui::REPEATERS_JS) type="module"></script>
619 }
620 .boxed(),
621 None => view! {
622 cx =>
623 topcoat::dev::script()
624 tablo_ui::theme_init_script(default_dark: default_dark)
625 }
626 .boxed(),
627 };
628 let dark = match request_cookie(cx, "theme").as_deref() {
629 Some("dark") => true,
630 Some("light") => false,
631 _ => default_dark,
632 };
633 let html_class = dark.then_some("dark");
634 Ok(view! {
635 cx =>
636 <!DOCTYPE html>
637 <html class=(html_class)>
638 <head>
639 <title>(title)</title>
640 (head)
641 </head>
642 <body>(body)</body>
643 </html>
644 }
645 .boxed())
646 }
647}
648
649/// The request's panel brand name, `Tablo` without one.
650fn brand_name(cx: &Cx) -> String {
651 current(cx)
652 .and_then(|panel| panel.brand.as_ref())
653 .map_or_else(|| "Tablo".to_string(), |brand| brand.name.clone())
654}
655
656#[cfg(test)]
657mod tests;