// 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" } }
}
}
}