fabrica

hanna/fabrica

7790 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The page shell: `<head>`, top bar, off-canvas drawer, theme picker.
6//!
7//! Every full page is wrapped by [`page`]; htmx partials return fragments instead.
8//! Structure is server-rendered and works without JavaScript — the drawer is a
9//! `:target`-free CSS class toggled by `fabrica.js`, the theme picker is a real
10//! form, and every action is a link or form.
11
12use maud::{DOCTYPE, Markup, html};
13use model::User;
14
15use crate::assets::{Scheme, ThemeInfo};
16use crate::icons::{Icon, icon};
17
18/// The per-request chrome data the shell needs.
19pub struct Chrome {
20 /// The instance name shown in the header and titles.
21 pub instance_name: String,
22 /// The current viewer, if signed in.
23 pub user: Option<User>,
24 /// The active theme name and scheme.
25 pub active_theme: String,
26 /// The active theme's light/dark scheme.
27 pub active_scheme: Scheme,
28 /// The themes offered by the picker.
29 pub themes: Vec<ThemeInfo>,
30 /// Whether the theme picker is shown.
31 pub allow_theme_choice: bool,
32 /// Whether web self-registration is enabled (drives the Sign-up links).
33 pub allow_registration: bool,
34 /// The current request path, for the theme form's return and nav highlighting.
35 pub path: String,
36 /// The CSRF token for this session, injected into htmx and forms.
37 pub csrf: String,
38}
39
40/// Wrap `body` in the full page shell with `title`.
41#[must_use]
42#[allow(clippy::needless_pass_by_value)] // `body` is an owned fragment the shell embeds.
43pub fn page(chrome: &Chrome, title: &str, body: Markup) -> Markup {
44 html! {
45 (DOCTYPE)
46 html lang="en" data-theme=(chrome.active_theme)
47 style={ "color-scheme: " (chrome.active_scheme.as_str()) } {
48 head {
49 meta charset="utf-8";
50 meta name="viewport" content="width=device-width, initial-scale=1";
51 title { (title) " · " (chrome.instance_name) }
52 link rel="stylesheet" href="/assets/base.css";
53 link rel="stylesheet" href={ "/theme/" (chrome.active_theme) ".css" };
54 // Operator-supplied overrides, injected after the theme.
55 link rel="stylesheet" href="/assets/custom.css";
56 script src="/assets/htmx.min.js" defer {}
57 script src="/assets/fabrica.js" defer {}
58 }
59 body hx-headers=(format!(r#"{{"X-CSRF-Token":"{}"}}"#, chrome.csrf)) {
60 a class="skip-link" href="#main" { "Skip to content" }
61 (topbar(chrome))
62 (drawer(chrome))
63 div class="drawer-backdrop" {}
64 main id="main" class="page" { (body) }
65 }
66 }
67 }
68}
69
70/// The slim top bar.
71fn topbar(chrome: &Chrome) -> Markup {
72 html! {
73 header class="topbar" {
74 button class="icon-btn" type="button" data-drawer-toggle aria-label="Menu"
75 aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) }
76 span class="slug" { a href="/" { (chrome.instance_name) } }
77 @if let Some(user) = &chrome.user {
78 (create_menu(chrome))
79 a class="icon-btn" href={ "/" (user.username) "?tab=bookmarks" }
80 aria-label="Bookmarks" title="Bookmarks" { (icon(Icon::Bookmark)) }
81 a class="icon-btn" href="/notifications"
82 aria-label="Notifications" title="Notifications" { (icon(Icon::Bell)) }
83 }
84 form class="topbar-search" method="get" action="/search" role="search" {
85 input type="search" name="q" placeholder="Search…" aria-label="Search";
86 }
87 }
88 }
89}
90
91/// The create (`+`) popover: New repository, New issue, and New pull request. The
92/// issue/PR items go to a chooser that lets the viewer pick a repository. A native
93/// `<details>`, so it opens without JavaScript; `fabrica.js` adds outside-click
94/// and Escape closing.
95fn create_menu(_chrome: &Chrome) -> Markup {
96 html! {
97 details class="topbar-create menu" data-menu {
98 summary class="icon-btn" aria-label="Create new" title="Create new" {
99 (icon(Icon::Plus))
100 }
101 div class="menu-popover" role="menu" {
102 a class="menu-item" role="menuitem" href="/new" {
103 (icon(Icon::Box)) span { "New repository" }
104 }
105 a class="menu-item" role="menuitem" href="/new/migrate" {
106 (icon(Icon::Download)) span { "New migration" }
107 }
108 a class="menu-item" role="menuitem" href="/new/group" {
109 (icon(Icon::Folder)) span { "New group" }
110 }
111 a class="menu-item" role="menuitem" href="/new/issue" {
112 (icon(Icon::Issue)) span { "New issue" }
113 }
114 a class="menu-item" role="menuitem" href="/new/pull" {
115 (icon(Icon::GitPull)) span { "New pull request" }
116 }
117 }
118 }
119 }
120}
121
122/// The off-canvas navigation drawer.
123fn drawer(chrome: &Chrome) -> Markup {
124 html! {
125 aside id="drawer" class="drawer" aria-label="Navigation" {
126 h2 { (chrome.instance_name) }
127 nav {
128 @if let Some(user) = &chrome.user {
129 a href="/" { (icon(Icon::Dashboard)) span { "Dashboard" } }
130 a href="/explore" { (icon(Icon::Compass)) span { "Explore" } }
131 a href={ "/" (user.username) } { (icon(Icon::User)) span { "Your profile" } }
132 } @else {
133 a href="/explore" { (icon(Icon::Compass)) span { "Explore" } }
134 }
135 }
136 hr;
137 @if chrome.allow_theme_choice && !chrome.themes.is_empty() {
138 (theme_picker(chrome))
139 hr;
140 }
141 nav {
142 @if let Some(user) = &chrome.user {
143 @if user.is_admin {
144 a href="/admin" { (icon(Icon::Dashboard)) span { "Admin" } }
145 }
146 a href="/settings" { (icon(Icon::Settings)) span { "Settings" } }
147 form method="post" action="/logout" {
148 input type="hidden" name="_csrf" value=(chrome.csrf);
149 button class="drawer-item" type="submit" {
150 (icon(Icon::LogOut)) span { "Log out" }
151 }
152 }
153 } @else {
154 a href="/login" { (icon(Icon::User)) span { "Sign in" } }
155 @if chrome.allow_registration {
156 a href="/register" { (icon(Icon::Plus)) span { "Sign up" } }
157 }
158 }
159 }
160 }
161 }
162}
163
164/// The theme picker form (works without JS via the submit button).
165fn theme_picker(chrome: &Chrome) -> Markup {
166 html! {
167 form method="get" action="/settings/theme" {
168 label for="theme-select" { "Theme" }
169 input type="hidden" name="return" value=(chrome.path);
170 select id="theme-select" name="name" data-theme-picker {
171 @for theme in &chrome.themes {
172 option value=(theme.name)
173 data-scheme=(theme.scheme.as_str())
174 selected[theme.name == chrome.active_theme] {
175 (theme.name)
176 }
177 }
178 }
179 noscript { button class="btn" type="submit" { "Apply" } }
180 }
181 }
182}