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::{
15 Panel,
16 state::{ResourceTrail, current},
17};
18use crate::{navigation::NavigationItem, topcoat_compat::async_page};
19
20/// `extra` plus the attribute that sends a sidebar link through runtime
21/// navigation. The menu button writes the `href` itself, so this carries none.
22fn sidebar_link(cx: &Cx, mut extra: Attributes) -> Attributes {
23 let mut attrs = crate::navigation::runtime_link(cx, "");
24 attrs.remove("href");
25 extra.extend(attrs);
26 extra
27}
28
29/// The application-owned assets used by [`Panel::layout_shell`].
30///
31/// Tailwind's generated stylesheet is necessarily a call-site asset because
32/// every application scans a different source tree. The Panel therefore takes
33/// the generated stylesheet and the application's chosen font as values while
34/// still owning the document markup that links them.
35#[derive(Debug, Clone, Copy)]
36pub(crate) struct ShellAssets {
37 pub(crate) stylesheet: Asset,
38 pub(crate) font: Font,
39}
40
41impl Panel {
42 async fn sidebar_navigation<'a>(
43 cx: &'a Cx,
44 nav_items: &[NavigationItem],
45 current_path: &str,
46 current_query: &str,
47 mobile_open: Signal<bool>,
48 ) -> Result<BoxView<'a>> {
49 use tablo_ui::{
50 sidebar_group, sidebar_group_content, sidebar_group_label, sidebar_menu,
51 sidebar_menu_badge, sidebar_menu_button, sidebar_menu_item,
52 };
53 // A Panel resolves every item it owns (`Panel::resource`, `page`, `home`); one that
54 // reaches the sidebar unresolved has no URL to render: a framework bug, not user error.
55 debug_assert!(
56 nav_items.iter().all(|item| item.url().is_some()),
57 "navigation items are resolved by the Panel that owns them"
58 );
59 let nav_items = sidebar::in_sidebar_order(nav_items);
60 // One active entry, chosen the way the breadcrumbs choose theirs.
61 let active = crate::navigation::current_index(&nav_items, current_path, current_query);
62 let sections = sidebar::sidebar_sections(nav_items);
63
64 Ok(view! {
65 for (label, entries) in sections {
66 sidebar_group(
67 sidebar_group_label((label))
68 sidebar_group_content(
69 sidebar_menu(
70 for (index, item) in entries {
71 let is_active = active == Some(index);
72 let label = item.label.localize(cx);
73 let attrs = sidebar_link(
74 cx,
75 attributes! {
76 // Tapping a link in the mobile sheet closes
77 // it; on desktop the navigation is the effect.
78 @click=$(|_e: Event| mobile_open.set(false))
79 },
80 );
81 sidebar_menu_item(
82 sidebar_menu_button(
83 active: is_active,
84 href: item.url(),
85 tooltip: Some(label.as_str()),
86 attrs: attrs,
87 if let Some(data) = item.icon.clone() {
88 icon(data: data)
89 }
90 <span>(label.clone())</span>
91 )
92 if let Some(badge) = item.badge.clone() {
93 sidebar_menu_badge((badge))
94 }
95 )
96 }
97 )
98 )
99 )
100 }
101 }
102 .boxed())
103 }
104
105 /// Whether the persisted [`SIDEBAR_COOKIE`] asks for an expanded
106 /// desktop sidebar (default: expanded).
107 ///
108 /// The value only seeds the runtime
109 /// signal's initial `data-state`; after hydration the browser owns the
110 /// state, and `assets/sidebar.js` mirrors changes back to the cookie.
111 fn sidebar_starts_open(cx: &Cx) -> bool {
112 request_cookie(cx, SIDEBAR_COOKIE).as_deref() != Some("collapsed")
113 }
114
115 /// Renders the shell around `slot` with an optional outer class.
116 pub async fn render_shell<'a>(
117 cx: &'a Cx,
118 nav_items: &[NavigationItem],
119 current_path: &str,
120 slot: Child<'a>,
121 extra_class: Option<String>,
122 ) -> Result<BoxView<'a>> {
123 // A hoisting body: the sidebar signals are declared while this view
124 // resolves, and a page re-run resumes them from the client. The owned
125 // copies pin the caller's navigation to the lazy body's lifetime.
126 let nav_items = nav_items.to_vec();
127 let current_path = current_path.to_string();
128 Ok(async_page(async move {
129 Self::render_shell_body(cx, &nav_items, ¤t_path, slot, extra_class).await
130 }))
131 }
132
133 async fn render_shell_body<'a>(
134 cx: &'a Cx,
135 nav_items: &[NavigationItem],
136 current_path: &str,
137 slot: Child<'a>,
138 extra_class: Option<String>,
139 ) -> Result<BoxView<'a>> {
140 use tablo_ui::{
141 SeparatorOrientation, SidebarCollapsible, SidebarVariant, separator, sidebar,
142 sidebar_content, sidebar_footer, sidebar_header, sidebar_inset, sidebar_provider,
143 sidebar_trigger,
144 };
145
146 let sidebar_open = signal(cx, || Self::sidebar_starts_open(cx));
147 let mobile_open = signal(cx, || false);
148 let outer_class = extra_class.clone().unwrap_or_default();
149 let header_title = Self::brand_name(cx);
150 // One navigation tree: the upstream sidebar renders its children once
151 // and shares them between the desktop panel and the mobile sheet.
152 // The query the browser sent: an entry that names parameters the request carries wins
153 // over one that names none, at the same path.
154 let current_query = topcoat::router::request::original_uri(cx)
155 .query()
156 .unwrap_or_default();
157 let navigation = Self::sidebar_navigation(
158 cx,
159 nav_items,
160 current_path,
161 current_query,
162 mobile_open.clone(),
163 )
164 .await?;
165 let sidebar_brand = Self::render_brand(cx).await?;
166 let resources: Vec<&ResourceTrail> = current(cx)
167 .map(|panel| {
168 panel
169 .nav_items
170 .iter()
171 .filter_map(|entry| entry.resource_list.as_ref())
172 .collect()
173 })
174 .unwrap_or_default();
175 let trail = match super::error_page::current_error(cx) {
176 Some(error) => {
177 breadcrumbs::failed(nav_items, current_path, current_query, error.title())
178 }
179 None => breadcrumbs::trail(nav_items, &resources, current_path, current_query),
180 };
181 let area = current(cx).and_then(|panel| {
182 let area = panel.area_at(current_path)?;
183 Some((panel.areas[area].clone(), panel.realm.prefix.clone()))
184 });
185 let trail = match &area {
186 Some((area, _)) => breadcrumbs::within(area.label.clone(), &area.url, trail),
187 None => trail,
188 };
189 let back = area.map_or_else(|| ().boxed(), |(_, home)| area::back_link(cx, &home));
190 let crumbs = breadcrumbs::render(cx, trail);
191 // The user and tenant menus, present only with a session (ADR-0013). `ensure_token` runs
192 // before any streaming starts so their forms, and the language menu's, always carry a
193 // matching CSRF pair.
194 let (user_menu, tenant_menu): (BoxView<'_>, BoxView<'_>) = match crate::auth::signed(cx) {
195 Some(signed) => {
196 let csrf = crate::csrf::ensure_token(cx);
197 let items: Vec<NavigationItem> = user_menu::account_items(cx)
198 .into_iter()
199 .chain(
200 current(cx)
201 .map(|panel| panel.user_menu.clone())
202 .unwrap_or_default(),
203 )
204 .collect();
205 (
206 Self::user_menu(
207 cx,
208 signed.user.as_ref(),
209 &items,
210 crate::auth::logout_url(cx),
211 &csrf,
212 ),
213 Self::tenant_menu(cx, signed.user.tenants(), &csrf),
214 )
215 }
216 // Without a session the footer still offers the theme, and the language where the
217 // panel has one to pick: only that menu's forms need a token here.
218 None => {
219 let csrf = if crate::i18n::offered(cx).len() > 1 {
220 crate::csrf::ensure_token(cx)
221 } else {
222 String::new()
223 };
224 (Self::theme_menu(cx, &csrf), ().boxed())
225 }
226 };
227 let toaster = Self::toaster(cx).await?;
228
229 Ok(view! {
230 sidebar_provider(
231 attrs: attributes! { class=(outer_class) },
232 // No `sidebar_rail`: the header's `sidebar_trigger` is the one toggle.
233 sidebar(
234 open: $(sidebar_open.get()),
235 mobile_open: $(mobile_open.get()),
236 collapsible: SidebarCollapsible::Icon,
237 variant: SidebarVariant::Inset,
238 sheet_attrs: attributes! {
239 id="mobile-sidebar-sheet"
240 aria-label=(crate::t!(cx, "tablo-shell-navigation"))
241 @keydown=$(|e: Event| {
242 if e.key == "Escape" {
243 mobile_open.set(false);
244 }
245 })
246 @click=$(|e: Event| {
247 if e.target.id == "mobile-sidebar-sheet" {
248 mobile_open.set(false);
249 }
250 })
251 },
252 sidebar_header(
253 // Collapsed to icons, the rail leaves the brand only its mark: centred,
254 // it lines up with the menu icons below.
255 <div
256 class="flex items-center gap-2 px-2 md:group-data-[collapsible=icon]/sidebar:justify-center md:group-data-[collapsible=icon]/sidebar:px-0"
257 >
258 (sidebar_brand)
259 // Below `md` the sheet covers the inset header,
260 // so the sheet's own header carries the close
261 // control (upstream `examples/ui`); the mobile
262 // trigger in the inset header is behind the veil.
263 tablo_ui::button(
264 variant: tablo_ui::ButtonVariant::Ghost,
265 size: tablo_ui::ButtonSize::Icon,
266 attrs: attributes! {
267 type="button"
268 class="md:hidden ml-auto"
269 aria-label=(crate::t!(cx, "tablo-shell-close-sidebar"))
270 @click=$(|_e: Event| mobile_open.set(false))
271 },
272 icon(data: tablo_ui::icons::X)
273 )
274 </div>
275 )
276 sidebar_content(
277 // The icon rail is too narrow for a scrollbar: it still scrolls, without
278 // one.
279 attrs: attributes! {
280 class="md:group-data-[collapsible=icon]/sidebar:[scrollbar-width:none]"
281 },
282 (tenant_menu)
283 (back)
284 (navigation)
285 )
286 sidebar_footer((user_menu))
287 )
288 sidebar_inset(
289 sidebar_header(
290 // The desktop trigger collapses the rail; below md the
291 // mobile trigger opens the sheet instead (shadcn
292 // SidebarTrigger pair, upstream `examples/ui`).
293 sidebar_trigger(
294 open: $(sidebar_open.get()),
295 attrs: attributes! {
296 class="max-md:hidden"
297 aria-controls="mobile-sidebar-sheet"
298 @click=$(|_e: Event| sidebar_open.toggle())
299 }
300 )
301 sidebar_trigger(
302 open: $(mobile_open.get()),
303 attrs: attributes! {
304 class="md:hidden"
305 aria-controls="mobile-sidebar-sheet"
306 @click=$(|_e: Event| mobile_open.toggle())
307 }
308 )
309 // The sidebar carries the brand on desktop; below md it hides in
310 // the sheet, so the topbar names the panel there.
311 <div
312 class="min-w-0 truncate font-semibold text-foreground md:hidden"
313 >
314 (header_title)
315 </div>
316 // The separator stays a direct child of the topbar,
317 // where `sidebar_inset` sizes a vertical rule.
318 separator(
319 orientation: SeparatorOrientation::Vertical,
320 attrs: attributes! { class="max-sm:hidden" }
321 )
322 (crumbs)
323 (super::search::topbar_end(cx))
324 )
325 // `sidebar_inset` is the document's one `<main>`; a second
326 // nested landmark is invalid and confuses landmark
327 // navigation (upstream `examples/ui` uses a plain div).
328 // The page container (`tablo_ui::page`) owns the width
329 // and the padding.
330 (super::impersonation::banner(cx))
331 <div class="flex flex-1 flex-col">(slot)</div>
332 )
333 (toaster)
334 ) // Scripts are owned by the document (layout_shell).
335 }
336 .boxed())
337 }
338
339 /// The panel shell around a page, as a complete HTML document: the layout
340 /// every panel registers at its prefix unless [`Panel::layout`] replaces
341 /// it, and what a replacement calls to keep the shell around its own
342 /// markup.
343 ///
344 /// The stylesheet and font are supplied to the Panel builder with
345 /// [`Self::shell_assets`]. A Panel without those values remains renderable
346 /// for tests and custom document owners, but does not pretend that a CSS
347 /// bundle exists. Errors from the page slot propagate unchanged when the
348 /// document view is resolved.
349 pub fn layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> BoxView<'a> {
350 Box::pin(ThenView::new(Self::render_layout_shell(cx, slot)))
351 }
352
353 async fn render_layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> Result<BoxView<'a>> {
354 // A page a creatable choice's dialog frames shows only itself and its toasts.
355 if super::url::picking(cx) {
356 let body = async_page(async move {
357 Ok(view! {
358 // `main` paints the frame; the block inside it is what the frame fits.
359 <main class="min-h-dvh bg-card text-card-foreground">
360 <div class="px-6 pt-6 pb-6" data-pick-page="">(slot)</div>
361 </main>
362 (Self::toaster(cx).await?)
363 }
364 .boxed())
365 });
366 let panel = current(cx).map(|panel| &**panel);
367 return Self::render_document(cx, panel, Self::brand_name(cx), body).await;
368 }
369 // The browser's URL, not a rewrite's: the error page names where the visitor is.
370 let path = topcoat::router::request::original_uri(cx)
371 .path()
372 .to_string();
373 // The sidebar of the area the path is under, else the panel's, or a single Home entry
374 // outside any panel.
375 let panel = current(cx);
376 let entries: Vec<_> = panel
377 .map(|panel| area::listed(cx, panel, panel.area_at(&path)))
378 .unwrap_or_default();
379 // One count per badge, run together rather than one after another.
380 let badges = futures_util::future::join_all(entries.iter().map(|entry| async {
381 match &entry.badge {
382 Some(badge) => Some(badge(cx).await),
383 None => None,
384 }
385 }))
386 .await;
387 crate::notification::prefetch_bell(cx).await;
388 let mut nav_items: Vec<NavigationItem> = entries
389 .iter()
390 .zip(badges)
391 .map(|(entry, badge)| {
392 let mut item = area::item(cx, entry);
393 if let Some(badge) = badge {
394 item.badge = badge;
395 }
396 item
397 })
398 .collect();
399 if nav_items.is_empty() {
400 nav_items.push(NavigationItem::at(
401 crate::t!(cx, "tablo-shell-home"),
402 super::gate::panel_prefix(cx),
403 ));
404 }
405 let shell = Self::render_shell(cx, &nav_items, &path, slot, None).await?;
406 Self::render_document(
407 cx,
408 current(cx).map(|panel| &**panel),
409 Self::brand_name(cx),
410 shell,
411 )
412 .await
413 }
414}
415
416mod area;
417mod brand;
418mod breadcrumbs;
419mod document;
420mod environment;
421mod language;
422pub(super) mod sidebar;
423mod theme;
424mod toaster;
425mod user_menu;
426
427pub(in crate::panel) use area::area_entries;
428pub use brand::{Brand, Radius};
429pub use environment::Environment;
430pub(crate) use language::{LANGUAGE_SEGMENT, language_post};
431use theme::{SIDEBAR_COOKIE, Theme};
432
433use crate::cookie::request_cookie;