Skip to main content

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, &current_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::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
620/// The request's panel brand name, `Tablo` without one.
621fn 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;