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 admin 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 URL 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.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 // Tapping a link in the mobile sheet closes
288 // it; on desktop the navigation is the effect.
289 @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 /// Whether the persisted `sidebar_state` cookie asks for an expanded
313 /// desktop sidebar (default: expanded).
314 ///
315 /// The value only seeds the runtime
316 /// signal's initial `data-state`; after hydration the browser owns the
317 /// state, and `assets/sidebar.js` mirrors changes back to the cookie.
318 fn sidebar_starts_open(cx: &Cx) -> bool {
319 request_cookie(cx, "sidebar_state").as_deref() != Some("collapsed")
320 }
321
322 /// Renders the shell around `slot` with an optional outer class.
323 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 // A hoisting body: the sidebar signals are declared while this view
331 // resolves, and a page re-run resumes them from the client. The owned
332 // copies pin the caller's navigation to the lazy body's lifetime.
333 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 // One navigation tree: the upstream sidebar renders its children once
357 // and shares them between the desktop panel and the mobile sheet.
358 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 // Signed-in identity + logout control, present only with a session
363 // (ADR-0013). `ensure_token` runs before any streaming starts so the
364 // logout form always carries a matching CSRF pair.
365 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 // No `<span>` placeholder: the toaster renders an `<ol>`,
406 // which permits only `li`/`script`/`template` children — the empty
407 // view renders nothing.
408 None => ().boxed(),
409 };
410 // The page owns the live-toast signals; resolve the same handles here
411 // (same helper, same request identity) and hand them to the shard.
412 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_rail` is intentionally not rendered: the header
424 // `sidebar_trigger` is the explicit toggle, and the rail's
425 // edge hit-area reads as stray chrome as a primary toggle.
426 // Keep the component available (`tablo_ui::sidebar_rail`)
427 // for opt-in `variant=inset`/`floating` layouts.
428 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 // Below `md` the sheet covers the inset header,
450 // so the sheet's own header carries the close
451 // control (upstream `examples/ui`); the mobile
452 // trigger in the inset header is behind the veil.
453 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 // The desktop trigger collapses the rail; below md the
471 // mobile trigger opens the sheet instead (shadcn
472 // SidebarTrigger pair, upstream `examples/ui`).
473 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 // The sidebar carries the brand on desktop; below md
490 // it hides in the sheet, so the topbar names the
491 // panel there.
492 <div class="md:hidden font-semibold text-foreground">
493 (header_title)
494 </div>
495 // The separator stays a direct child of the topbar,
496 // where `sidebar_inset` sizes a vertical rule.
497 <div class="ml-auto">(theme_toggle)</div>
498 separator(orientation: SeparatorOrientation::Vertical)
499 (account_view)
500 )
501 // `sidebar_inset` is the document's one `<main>`; a second
502 // nested landmark is invalid and confuses landmark
503 // navigation (upstream `examples/ui` uses a plain div).
504 // The page container (`tablo_ui::page`) owns the width
505 // and the padding.
506 <div class="flex flex-1 flex-col">(slot)</div>
507 )
508 // Toast stack — the shadcn/Sonner surface, fixed bottom-right
509 // and a polite live region so streamed swaps are announced.
510 // `live_toaster` is the page-owned
511 // in-place transport; the flash cookie's toast
512 // rides beside it.
513 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 ) // Scripts are owned by the document (layout_shell).
523 }
524 .boxed())
525 }
526
527 /// The panel shell around a page, as a complete HTML document: the layout
528 /// every panel registers at its prefix unless [`Panel::layout`] replaces
529 /// it, and what a replacement calls to keep the shell around its own
530 /// markup.
531 ///
532 /// The stylesheet and font are supplied to the Panel builder with
533 /// [`Self::shell_assets`]. A Panel without those values remains renderable
534 /// for tests and custom document owners, but does not pretend that a CSS
535 /// bundle exists. Errors from the page slot propagate unchanged when the
536 /// document view is resolved.
537 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 // The panel's sidebar, or a single Home entry outside any panel.
545 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 /// Renders a complete HTML document around a page outside the panel prefix.
554 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 /// Renders the HTML document with assets and the dark-mode class.
563 pub(crate) async fn render_document<'a>(
564 cx: &'a Cx,
565 title: String,
566 body: BoxView<'a>,
567 ) -> Result<BoxView<'a>> {
568 // The panel's default: the `<html class>` a first-time visitor gets,
569 // and the fallback the blocking script uses when nothing is stored.
570 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::SELECTS_JS) defer=""></script>
582 }
583 .boxed(),
584 None => view! {
585 cx =>
586 topcoat::dev::script()
587 tablo_ui::theme_init_script(default_dark: default_dark)
588 }
589 .boxed(),
590 };
591 let dark = match request_cookie(cx, "theme").as_deref() {
592 Some("dark") => true,
593 Some("light") => false,
594 _ => default_dark,
595 };
596 let html_class = dark.then_some("dark");
597 Ok(view! {
598 cx =>
599 <!DOCTYPE html>
600 <html class=(html_class)>
601 <head>
602 <title>(title)</title>
603 (head)
604 </head>
605 <body>(body)</body>
606 </html>
607 }
608 .boxed())
609 }
610}
611
612/// The request's panel brand name, `Tablo` without one.
613fn brand_name(cx: &Cx) -> String {
614 current(cx)
615 .and_then(|panel| panel.brand.as_ref())
616 .map_or_else(|| "Tablo".to_string(), |brand| brand.name.clone())
617}
618
619#[cfg(test)]
620mod tests;