tablo_core/panel/shell.rs
1//! Renders the shell framing every admin page.
2
3use topcoat::{
4 Result,
5 asset::Asset,
6 context::Cx,
7 font::Font,
8 icon::icon,
9 router::Slot,
10 runtime::{Event, Signal, signal},
11 view::{Attributes, BoxView, Child, ViewExt, attributes, internal::ThenView, view},
12};
13
14use super::{Panel, state::current};
15use crate::{
16 navigation::NavigationItem,
17 notification::{LiveToast, live_toast, live_toaster, take_notification},
18 topcoat_compat::async_page,
19};
20
21/// `extra` plus the attribute that sends a sidebar link through runtime
22/// navigation. The menu button writes the `href` itself, so this carries none.
23fn sidebar_link(cx: &Cx, mut extra: Attributes) -> Attributes {
24 let mut attrs = crate::navigation::runtime_link(cx, "");
25 attrs.remove("href");
26 extra.extend(attrs);
27 extra
28}
29
30/// Branding for the panel's shell: the sidebar header, the login card, and the
31/// topbar below `md`, where the sidebar folds into a sheet.
32#[derive(Debug, Clone)]
33pub struct Brand {
34 /// Display name (e.g. `"Acme"`).
35 pub name: String,
36 /// Optional logo URL (e.g. `"/logo.svg"`). Rendered as an `<img>` when present.
37 pub logo: Option<String>,
38 /// Optional favicon URL (e.g. `"/favicon.svg"`), the `<link rel="icon">` of every page the
39 /// panel renders, its sign-in pages included.
40 pub favicon: Option<String>,
41}
42
43impl Brand {
44 /// Create a brand with the given name (surrounding whitespace is
45 /// trimmed so `" Acme "` cannot break the `flex h-16` header).
46 pub fn new(name: impl Into<String>) -> Self {
47 Self {
48 name: name.into().trim().to_string(),
49 logo: None,
50 favicon: None,
51 }
52 }
53
54 /// Attach a logo URL (blank values are ignored so an empty
55 /// `logo("")` falls back to the name-only render instead of a
56 /// broken-image icon).
57 pub fn logo(mut self, logo: impl Into<String>) -> Self {
58 let logo = logo.into().trim().to_string();
59 if !logo.is_empty() {
60 self.logo = Some(logo);
61 }
62 self
63 }
64
65 /// Attach a favicon URL; a blank value is ignored, leaving the browser's default.
66 pub fn favicon(mut self, favicon: impl Into<String>) -> Self {
67 let favicon = favicon.into().trim().to_string();
68 if !favicon.is_empty() {
69 self.favicon = Some(favicon);
70 }
71 self
72 }
73}
74
75/// Where a brand mark renders, which sets its size.
76#[derive(Clone, Copy)]
77enum BrandSize {
78 /// The sidebar header and the narrow topbar: 28px.
79 Sidebar,
80 /// Above the card of a page outside the shell, such as sign-in: 40px.
81 Page,
82}
83
84/// The application-owned assets used by [`Panel::layout_shell`].
85///
86/// Tailwind's generated stylesheet is necessarily a call-site asset because
87/// every application scans a different source tree. The Panel therefore takes
88/// the generated stylesheet and the application's chosen font as values while
89/// still owning the document markup that links them.
90#[derive(Debug, Clone, Copy)]
91pub(crate) struct ShellAssets {
92 pub(crate) stylesheet: Asset,
93 pub(crate) font: Font,
94}
95
96impl Panel {
97 /// The panel's brand: its logo, or its initial on the primary color, and its name.
98 fn current_brand(cx: &Cx) -> (String, Option<String>) {
99 match current(cx).and_then(|panel| panel.brand.as_ref()) {
100 Some(brand) => (brand.name.clone(), brand.logo.clone()),
101 None => ("Tablo".to_string(), None),
102 }
103 }
104
105 /// The request's panel brand name, `Tablo` without one.
106 pub(crate) fn brand_name(cx: &Cx) -> String {
107 Self::current_brand(cx).0
108 }
109
110 /// The brand's mark at `size`: its logo, or without one its initial on the primary token, so
111 /// a header keeps its mark.
112 fn brand_mark<'a>(
113 cx: &'a Cx,
114 name: &str,
115 logo: Option<String>,
116 size: BrandSize,
117 ) -> BoxView<'a> {
118 // A page logo keeps its own width at the page mark's height.
119 let (width, height, logo_class, initial_class) = match size {
120 BrandSize::Sidebar => (
121 Some("28"),
122 "28",
123 "size-7 shrink-0 rounded-md",
124 "flex size-7 shrink-0 items-center justify-center rounded-md bg-primary text-sm \
125 font-semibold text-primary-foreground",
126 ),
127 BrandSize::Page => (
128 None,
129 "40",
130 "h-10 w-auto shrink-0",
131 "flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary text-lg \
132 font-semibold text-primary-foreground",
133 ),
134 };
135 match logo {
136 Some(logo_url) => {
137 // A page always shows the name beside its logo, so the logo is decoration there;
138 // the icon rail hides the sidebar's name, so the sidebar logo names the brand.
139 let alt = match size {
140 BrandSize::Sidebar => name.to_string(),
141 BrandSize::Page => String::new(),
142 };
143 view! {
144 cx =>
145 <img
146 src=(logo_url)
147 alt=(alt)
148 width=(width)
149 height=(height)
150 class=(logo_class)
151 >
152 }
153 .boxed()
154 }
155 None => {
156 let initial = name.chars().next().map(String::from).unwrap_or_default();
157 view! {
158 cx =>
159 <span aria-hidden="true" class=(initial_class)>(initial)</span>
160 }
161 .boxed()
162 }
163 }
164 }
165
166 /// The brand as the sidebar header shows it: the mark beside the name.
167 pub(crate) async fn render_brand(cx: &Cx) -> Result<BoxView<'_>> {
168 let (name, logo) = Self::current_brand(cx);
169 let mark = Self::brand_mark(cx, &name, logo, BrandSize::Sidebar);
170 Ok(view! {
171 cx =>
172 <div class="flex min-w-0 items-center gap-2 font-semibold text-foreground">
173 (mark)
174 <span class="truncate md:group-data-[collapsible=icon]/sidebar:hidden">
175 (name)
176 </span>
177 </div>
178 }
179 .boxed())
180 }
181
182 /// The brand as a page outside the shell shows it, above its card: the mark over the name.
183 pub(crate) fn render_page_brand(cx: &Cx) -> BoxView<'_> {
184 let (name, logo) = Self::current_brand(cx);
185 let mark = Self::brand_mark(cx, &name, logo, BrandSize::Page);
186 view! {
187 cx =>
188 <div class="flex flex-col items-center gap-3 text-center" data-brand="">
189 (mark)
190 <span class="text-lg font-semibold tracking-tight text-foreground">
191 (name)
192 </span>
193 </div>
194 }
195 .boxed()
196 }
197
198 async fn sidebar_navigation<'a>(
199 cx: &'a Cx,
200 nav_items: &[NavigationItem],
201 current_path: &str,
202 current_query: &str,
203 mobile_open: Signal<bool>,
204 ) -> Result<BoxView<'a>> {
205 use tablo_ui::{
206 sidebar_group, sidebar_group_content, sidebar_group_label, sidebar_menu,
207 sidebar_menu_badge, sidebar_menu_button, sidebar_menu_item,
208 };
209 // A Panel resolves every item it owns (`Panel::resource`, `page`, `home`); one that
210 // reaches the sidebar unresolved has no URL to render: a framework bug, not user error.
211 debug_assert!(
212 nav_items.iter().all(|item| item.url().is_some()),
213 "navigation items are resolved by the Panel that owns them"
214 );
215 let nav_items = sidebar::in_sidebar_order(nav_items);
216 // One active entry, chosen the way the breadcrumbs choose theirs.
217 let active = crate::navigation::current_index(&nav_items, current_path, current_query);
218 let sections = sidebar::sidebar_sections(nav_items);
219
220 Ok(view! {
221 cx =>
222 for (label, entries) in sections {
223 sidebar_group(
224 sidebar_group_label((label))
225 sidebar_group_content(
226 sidebar_menu(
227 for (index, item) in entries {
228 let is_active = active == Some(index);
229 let label = item.label.localize(cx);
230 let attrs = sidebar_link(
231 cx,
232 attributes! {
233 // Tapping a link in the mobile sheet closes
234 // it; on desktop the navigation is the effect.
235 @click=$(|_e: Event| mobile_open.set(false))
236 },
237 );
238 sidebar_menu_item(
239 sidebar_menu_button(
240 active: is_active,
241 href: item.url(),
242 tooltip: Some(label.as_str()),
243 attrs: attrs,
244 if let Some(data) = item.icon.clone() {
245 icon(data: data)
246 }
247 <span>(label.clone())</span>
248 )
249 if let Some(badge) = item.badge.clone() {
250 sidebar_menu_badge((badge))
251 }
252 )
253 }
254 )
255 )
256 )
257 }
258 }
259 .boxed())
260 }
261
262 /// Whether the persisted [`SIDEBAR_COOKIE`] asks for an expanded
263 /// desktop sidebar (default: expanded).
264 ///
265 /// The value only seeds the runtime
266 /// signal's initial `data-state`; after hydration the browser owns the
267 /// state, and `assets/sidebar.js` mirrors changes back to the cookie.
268 fn sidebar_starts_open(cx: &Cx) -> bool {
269 request_cookie(cx, SIDEBAR_COOKIE).as_deref() != Some("collapsed")
270 }
271
272 /// Renders the shell around `slot` with an optional outer class.
273 pub async fn render_shell<'a>(
274 cx: &'a Cx,
275 nav_items: &[NavigationItem],
276 current_path: &str,
277 slot: Child<'a>,
278 extra_class: Option<String>,
279 ) -> Result<BoxView<'a>> {
280 // A hoisting body: the sidebar signals are declared while this view
281 // resolves, and a page re-run resumes them from the client. The owned
282 // copies pin the caller's navigation to the lazy body's lifetime.
283 let nav_items = nav_items.to_vec();
284 let current_path = current_path.to_string();
285 Ok(async_page(async move {
286 Self::render_shell_body(cx, &nav_items, ¤t_path, slot, extra_class).await
287 }))
288 }
289
290 async fn render_shell_body<'a>(
291 cx: &'a Cx,
292 nav_items: &[NavigationItem],
293 current_path: &str,
294 slot: Child<'a>,
295 extra_class: Option<String>,
296 ) -> Result<BoxView<'a>> {
297 use tablo_ui::{
298 SeparatorOrientation, SidebarCollapsible, SidebarVariant, separator, sidebar,
299 sidebar_content, sidebar_footer, sidebar_header, sidebar_inset, sidebar_provider,
300 sidebar_trigger,
301 };
302
303 let sidebar_open = signal(cx, || Self::sidebar_starts_open(cx));
304 let mobile_open = signal(cx, || false);
305 let outer_class = extra_class.clone().unwrap_or_default();
306 let header_title = Self::brand_name(cx);
307 // One navigation tree: the upstream sidebar renders its children once
308 // and shares them between the desktop panel and the mobile sheet.
309 // The query the browser sent: an entry that names parameters the request carries wins
310 // over one that names none, at the same path.
311 let current_query = topcoat::router::request::original_uri(cx)
312 .query()
313 .unwrap_or_default();
314 let navigation = Self::sidebar_navigation(
315 cx,
316 nav_items,
317 current_path,
318 current_query,
319 mobile_open.clone(),
320 )
321 .await?;
322 let sidebar_brand = Self::render_brand(cx).await?;
323 let resource_lists: Vec<&str> = current(cx)
324 .map(|panel| {
325 panel
326 .nav_items
327 .iter()
328 .filter_map(|entry| entry.resource_list.as_deref())
329 .collect()
330 })
331 .unwrap_or_default();
332 let trail = match super::error_page::current_error(cx) {
333 Some(error) => {
334 breadcrumbs::failed(nav_items, current_path, current_query, error.title())
335 }
336 None => breadcrumbs::trail(nav_items, &resource_lists, current_path, current_query),
337 };
338 let crumbs = breadcrumbs::render(cx, trail);
339 // The user and tenant menus, present only with a session (ADR-0013). `ensure_token` runs
340 // before any streaming starts so their forms always carry a matching CSRF pair.
341 let (user_menu, tenant_menu): (BoxView<'_>, BoxView<'_>) = match crate::auth::signed(cx) {
342 Some(signed) => {
343 let csrf = crate::csrf::ensure_token(cx);
344 let items: Vec<NavigationItem> = user_menu::account_items(cx)
345 .into_iter()
346 .chain(
347 current(cx)
348 .map(|panel| panel.user_menu.clone())
349 .unwrap_or_default(),
350 )
351 .collect();
352 (
353 Self::user_menu(
354 cx,
355 signed.user.as_ref(),
356 &items,
357 crate::auth::logout_url(cx),
358 &csrf,
359 ),
360 Self::tenant_menu(cx, signed.user.tenants(), &csrf),
361 )
362 }
363 // Without a session the footer still offers the theme.
364 None => (Self::theme_menu(cx), ().boxed()),
365 };
366 let notification_view: BoxView<'_> = match take_notification(cx) {
367 Some(notification) => {
368 crate::notification::render_notification(cx, notification, Default::default())
369 .await?
370 }
371 // No `<span>` placeholder: the toaster renders an `<ol>`,
372 // which permits only `li`/`script`/`template` children — the empty
373 // view renders nothing.
374 None => ().boxed(),
375 };
376 // The page owns the live-toast signals; resolve the same handles here
377 // (same helper, same request identity) and hand them to the shard.
378 let LiveToast {
379 status: toast_status,
380 title: toast_title,
381 description: toast_description,
382 serial: toast_serial,
383 } = live_toast(cx);
384
385 Ok(view! {
386 cx =>
387 sidebar_provider(
388 attrs: attributes! { class=(outer_class) },
389 // No `sidebar_rail`: the header's `sidebar_trigger` is the one toggle.
390 sidebar(
391 open: $(sidebar_open.get()),
392 mobile_open: $(mobile_open.get()),
393 collapsible: SidebarCollapsible::Icon,
394 variant: SidebarVariant::Inset,
395 sheet_attrs: attributes! {
396 id="mobile-sidebar-sheet"
397 aria-label=(crate::t!(cx, "tablo-shell-navigation"))
398 @keydown=$(|e: Event| {
399 if e.key == "Escape" {
400 mobile_open.set(false);
401 }
402 })
403 @click=$(|e: Event| {
404 if e.target.id == "mobile-sidebar-sheet" {
405 mobile_open.set(false);
406 }
407 })
408 },
409 sidebar_header(
410 // Collapsed to icons, the rail leaves the brand only its mark: centred,
411 // it lines up with the menu icons below.
412 <div
413 class="flex items-center gap-2 px-2 md:group-data-[collapsible=icon]/sidebar:justify-center md:group-data-[collapsible=icon]/sidebar:px-0"
414 >
415 (sidebar_brand)
416 // Below `md` the sheet covers the inset header,
417 // so the sheet's own header carries the close
418 // control (upstream `examples/ui`); the mobile
419 // trigger in the inset header is behind the veil.
420 tablo_ui::button(
421 variant: tablo_ui::ButtonVariant::Ghost,
422 size: tablo_ui::ButtonSize::Icon,
423 attrs: attributes! {
424 type="button"
425 class="md:hidden ml-auto"
426 aria-label=(crate::t!(cx, "tablo-shell-close-sidebar"))
427 @click=$(|_e: Event| mobile_open.set(false))
428 },
429 icon(data: tablo_ui::icons::X)
430 )
431 </div>
432 )
433 sidebar_content(
434 // The icon rail is too narrow for a scrollbar: it still scrolls, without
435 // one.
436 attrs: attributes! {
437 class="md:group-data-[collapsible=icon]/sidebar:[scrollbar-width:none]"
438 },
439 (tenant_menu)
440 (navigation)
441 )
442 sidebar_footer((user_menu))
443 )
444 sidebar_inset(
445 sidebar_header(
446 // The desktop trigger collapses the rail; below md the
447 // mobile trigger opens the sheet instead (shadcn
448 // SidebarTrigger pair, upstream `examples/ui`).
449 sidebar_trigger(
450 open: $(sidebar_open.get()),
451 attrs: attributes! {
452 class="max-md:hidden"
453 aria-controls="mobile-sidebar-sheet"
454 @click=$(|_e: Event| sidebar_open.toggle())
455 }
456 )
457 sidebar_trigger(
458 open: $(mobile_open.get()),
459 attrs: attributes! {
460 class="md:hidden"
461 aria-controls="mobile-sidebar-sheet"
462 @click=$(|_e: Event| mobile_open.toggle())
463 }
464 )
465 // The sidebar carries the brand on desktop; below md it hides in
466 // the sheet, so the topbar names the panel there.
467 <div
468 class="min-w-0 truncate font-semibold text-foreground md:hidden"
469 >
470 (header_title)
471 </div>
472 // The separator stays a direct child of the topbar,
473 // where `sidebar_inset` sizes a vertical rule.
474 separator(
475 orientation: SeparatorOrientation::Vertical,
476 attrs: attributes! { class="max-sm:hidden" }
477 )
478 (crumbs)
479 (super::search::topbar_end(cx))
480 )
481 // `sidebar_inset` is the document's one `<main>`; a second
482 // nested landmark is invalid and confuses landmark
483 // navigation (upstream `examples/ui` uses a plain div).
484 // The page container (`tablo_ui::page`) owns the width
485 // and the padding.
486 <div class="flex flex-1 flex-col">(slot)</div>
487 )
488 // Toast stack — the shadcn/Sonner surface, fixed bottom-right
489 // and a polite live region so streamed swaps are announced.
490 // `live_toaster` is the page-owned
491 // in-place transport; the flash cookie's toast
492 // rides beside it.
493 tablo_ui::toaster(
494 label: crate::t!(cx, "tablo-notifications"),
495 (notification_view)
496 live_toaster(
497 status: $(toast_status),
498 title: $(toast_title),
499 description: $(toast_description),
500 serial: $(toast_serial)
501 )
502 )
503 ) // Scripts are owned by the document (layout_shell).
504 }
505 .boxed())
506 }
507
508 /// The panel shell around a page, as a complete HTML document: the layout
509 /// every panel registers at its prefix unless [`Panel::layout`] replaces
510 /// it, and what a replacement calls to keep the shell around its own
511 /// markup.
512 ///
513 /// The stylesheet and font are supplied to the Panel builder with
514 /// [`Self::shell_assets`]. A Panel without those values remains renderable
515 /// for tests and custom document owners, but does not pretend that a CSS
516 /// bundle exists. Errors from the page slot propagate unchanged when the
517 /// document view is resolved.
518 pub fn layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> BoxView<'a> {
519 Box::pin(ThenView::new(Self::render_layout_shell(cx, slot)))
520 }
521
522 async fn render_layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> Result<BoxView<'a>> {
523 // The browser's URL, not a rewrite's: the error page names where the visitor is.
524 let path = topcoat::router::request::original_uri(cx)
525 .path()
526 .to_string();
527 // The panel's sidebar, or a single Home entry outside any panel.
528 let entries: Vec<_> = current(cx)
529 .iter()
530 .flat_map(|panel| &panel.nav_items)
531 .filter(|entry| (entry.visible)(cx))
532 .collect();
533 // One count per badge, run together rather than one after another.
534 let badges = futures_util::future::join_all(entries.iter().map(|entry| async {
535 match &entry.badge {
536 Some(badge) => Some(badge(cx).await),
537 None => None,
538 }
539 }))
540 .await;
541 crate::notification::prefetch_bell(cx).await;
542 let mut nav_items: Vec<NavigationItem> = entries
543 .iter()
544 .zip(badges)
545 .map(|(entry, badge)| {
546 let mut item = entry.item.clone();
547 if let Some(badge) = badge {
548 item.badge = badge;
549 }
550 item
551 })
552 .collect();
553 if nav_items.is_empty() {
554 nav_items.push(NavigationItem::at(
555 crate::t!(cx, "tablo-shell-home"),
556 super::gate::panel_prefix(cx),
557 ));
558 }
559 let shell = Self::render_shell(cx, &nav_items, &path, slot, None).await?;
560 Self::render_document(
561 cx,
562 current(cx).map(|panel| &**panel),
563 Self::brand_name(cx),
564 shell,
565 )
566 .await
567 }
568}
569
570mod breadcrumbs;
571mod document;
572pub(super) mod sidebar;
573mod theme;
574mod user_menu;
575
576use theme::{SIDEBAR_COOKIE, Theme, request_cookie};