Skip to main content

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, &current_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};