// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! The page shell: ``, top bar, off-canvas drawer, theme picker. //! //! Every full page is wrapped by [`page`]; htmx partials return fragments instead. //! Structure is server-rendered and works without JavaScript — the drawer is a //! `:target`-free CSS class toggled by `fabrica.js`, the theme picker is a real //! form, and every action is a link or form. use maud::{DOCTYPE, Markup, html}; use model::User; use crate::assets::{Scheme, ThemeInfo}; use crate::icons::{Icon, icon}; /// The per-request chrome data the shell needs. pub struct Chrome { /// The instance name shown in the header and titles. pub instance_name: String, /// The current viewer, if signed in. pub user: Option, /// The active theme name and scheme. pub active_theme: String, /// The active theme's light/dark scheme. pub active_scheme: Scheme, /// The themes offered by the picker. pub themes: Vec, /// Whether the theme picker is shown. pub allow_theme_choice: bool, /// Whether web self-registration is enabled (drives the Sign-up links). pub allow_registration: bool, /// The current request path, for the theme form's return and nav highlighting. pub path: String, /// The CSRF token for this session, injected into htmx and forms. pub csrf: String, } /// Wrap `body` in the full page shell with `title`. #[must_use] #[allow(clippy::needless_pass_by_value)] // `body` is an owned fragment the shell embeds. pub fn page(chrome: &Chrome, title: &str, body: Markup) -> Markup { html! { (DOCTYPE) html lang="en" data-theme=(chrome.active_theme) style={ "color-scheme: " (chrome.active_scheme.as_str()) } { head { meta charset="utf-8"; meta name="viewport" content="width=device-width, initial-scale=1"; title { (title) " · " (chrome.instance_name) } link rel="stylesheet" href="/assets/base.css"; link rel="stylesheet" href={ "/theme/" (chrome.active_theme) ".css" }; // Operator-supplied overrides, injected after the theme. link rel="stylesheet" href="/assets/custom.css"; script src="/assets/htmx.min.js" defer {} script src="/assets/fabrica.js" defer {} } body hx-headers=(format!(r#"{{"X-CSRF-Token":"{}"}}"#, chrome.csrf)) { a class="skip-link" href="#main" { "Skip to content" } (topbar(chrome)) (drawer(chrome)) div class="drawer-backdrop" {} main id="main" class="page" { (body) } } } } } /// The slim top bar. fn topbar(chrome: &Chrome) -> Markup { html! { header class="topbar" { button class="icon-btn" type="button" data-drawer-toggle aria-label="Menu" aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) } span class="slug" { a href="/" { (chrome.instance_name) } } @if let Some(user) = &chrome.user { (create_menu(chrome)) a class="icon-btn" href={ "/" (user.username) "?tab=bookmarks" } aria-label="Bookmarks" title="Bookmarks" { (icon(Icon::Bookmark)) } a class="icon-btn" href="/notifications" aria-label="Notifications" title="Notifications" { (icon(Icon::Bell)) } } form class="topbar-search" method="get" action="/search" role="search" { input type="search" name="q" placeholder="Search…" aria-label="Search"; } } } } /// The create (`+`) popover: New repository, New issue, and New pull request. The /// issue/PR items go to a chooser that lets the viewer pick a repository. A native /// `
`, so it opens without JavaScript; `fabrica.js` adds outside-click /// and Escape closing. fn create_menu(_chrome: &Chrome) -> Markup { html! { details class="topbar-create menu" data-menu { summary class="icon-btn" aria-label="Create new" title="Create new" { (icon(Icon::Plus)) } div class="menu-popover" role="menu" { a class="menu-item" role="menuitem" href="/new" { (icon(Icon::Box)) span { "New repository" } } a class="menu-item" role="menuitem" href="/new/migrate" { (icon(Icon::Download)) span { "New migration" } } a class="menu-item" role="menuitem" href="/new/group" { (icon(Icon::Folder)) span { "New group" } } a class="menu-item" role="menuitem" href="/new/issue" { (icon(Icon::Issue)) span { "New issue" } } a class="menu-item" role="menuitem" href="/new/pull" { (icon(Icon::GitPull)) span { "New pull request" } } } } } } /// The off-canvas navigation drawer. fn drawer(chrome: &Chrome) -> Markup { html! { aside id="drawer" class="drawer" aria-label="Navigation" { h2 { (chrome.instance_name) } nav { @if let Some(user) = &chrome.user { a href="/" { (icon(Icon::Dashboard)) span { "Dashboard" } } a href="/explore" { (icon(Icon::Compass)) span { "Explore" } } a href={ "/" (user.username) } { (icon(Icon::User)) span { "Your profile" } } } @else { a href="/explore" { (icon(Icon::Compass)) span { "Explore" } } } } hr; @if chrome.allow_theme_choice && !chrome.themes.is_empty() { (theme_picker(chrome)) hr; } nav { @if let Some(user) = &chrome.user { @if user.is_admin { a href="/admin" { (icon(Icon::Dashboard)) span { "Admin" } } } a href="/settings" { (icon(Icon::Settings)) span { "Settings" } } form method="post" action="/logout" { input type="hidden" name="_csrf" value=(chrome.csrf); button class="drawer-item" type="submit" { (icon(Icon::LogOut)) span { "Log out" } } } } @else { a href="/login" { (icon(Icon::User)) span { "Sign in" } } @if chrome.allow_registration { a href="/register" { (icon(Icon::Plus)) span { "Sign up" } } } } } } } } /// The theme picker form (works without JS via the submit button). fn theme_picker(chrome: &Chrome) -> Markup { html! { form method="get" action="/settings/theme" { label for="theme-select" { "Theme" } input type="hidden" name="return" value=(chrome.path); select id="theme-select" name="name" data-theme-picker { @for theme in &chrome.themes { option value=(theme.name) data-scheme=(theme.scheme.as_str()) selected[theme.name == chrome.active_theme] { (theme.name) } } } noscript { button class="btn" type="submit" { "Apply" } } } } }