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