c768b12c05b0fc8c40b3fec6daf3c6b5cda7d097 / frogue / src / views / components.rs
Raw
use maud::{Markup, Render, html};
/// The navigation: the ways in for a visitor, and the way back to oneself
/// and out again for someone signed in.
pub(super) struct Nav<'a> {
pub(super) viewer: Option<&'a str>,
}
impl Render for Nav<'_> {
fn render(&self) -> Markup {
let links = match self.viewer {
Some(name) => html! {
(FrogueUsername(name))
form class="session" method="post" action="/logout" {
button type="submit" { "Log out" }
}
},
None => html! {
a href="/login" { "Log in" }
a href="/register" { "Register" }
},
};
html! { nav { (links) } }
}
}
/// An error message, rendered only when there is one.
pub(super) struct Complaint<'a>(pub(super) Option<&'a str>);
impl Render for Complaint<'_> {
fn render(&self) -> Markup {
html! {
@if let Some(message) = self.0 {
p class="error" { (message) }
}
}
}
}
/// A repository heading: the owner, a slash, the slug, and the visibility indicator.
pub(super) struct RepositoryHeading<'a> {
pub(super) owner: &'a str,
pub(super) slug: &'a str,
pub(super) visibility: &'a str,
}
impl Render for RepositoryHeading<'_> {
fn render(&self) -> Markup {
html! {
h2 {
(FrogueUsername(self.owner)) "/" (self.slug)
" "
(FrogueRepositoryVisibility(self.visibility))
}
}
}
}
/// A text input field within a label.
pub(super) struct TextField<'a> {
pub(super) label: Option<&'a str>,
pub(super) name: &'a str,
pub(super) input_type: &'a str,
pub(super) value: Option<&'a str>,
pub(super) required: bool,
pub(super) maxlength: u32,
}
impl Render for TextField<'_> {
fn render(&self) -> Markup {
html! {
label {
@if let Some(label_text) = self.label {
(label_text)
}
input type=(self.input_type) name=(self.name) required[self.required]
value=(self.value.unwrap_or("")) maxlength=(self.maxlength);
}
}
}
}
/// A password input field within a label.
///
/// Password fields have no value attribute, so the browser never pre-fills
/// them from prior submissions.
pub(super) struct PasswordField<'a> {
pub(super) label: &'a str,
pub(super) name: &'a str,
pub(super) required: bool,
}
impl Render for PasswordField<'_> {
fn render(&self) -> Markup {
html! {
label {
(self.label)
input type="password" name=(self.name) required[self.required];
}
}
}
}
/// A textarea field within a label.
pub(super) struct TextArea<'a> {
pub(super) label: Option<&'a str>,
pub(super) name: &'a str,
pub(super) rows: u32,
pub(super) value: Option<&'a str>,
pub(super) required: bool,
}
impl Render for TextArea<'_> {
fn render(&self) -> Markup {
html! {
label {
@if let Some(label_text) = self.label {
(label_text)
}
textarea name=(self.name) rows=(self.rows) required[self.required] {
(self.value.unwrap_or(""))
}
}
}
}
}
/// A frogue username, with a link to the user and prefixed by '~'
pub(super) struct FrogueUsername<'a>(pub(super) &'a str);
impl Render for FrogueUsername<'_> {
fn render(&self) -> Markup {
let username = self.0;
html! {
a class="frogue-username" href={"/~" (username)} {"~" (username)}
}
}
}
/// A comment author, local or remote: local gets a `~username` link, remote gets
/// a link to their actor URL plus a badge saying so.
pub(super) struct AuthorName<'a>(pub(super) &'a crate::comments::CommentAuthor);
impl Render for AuthorName<'_> {
fn render(&self) -> Markup {
let author = self.0;
if author.is_remote {
html! {
@if let Some(url) = &author.actor_url {
a href=(url) { (author.name) }
} @else {
(author.name)
}
" "
span class="remote-badge" { "remote" }
}
} else {
html! { (FrogueUsername(&author.name)) }
}
}
}
/// A frogue repository visibility indicator
pub(super) struct FrogueRepositoryVisibility<'a>(pub(super) &'a str);
impl Render for FrogueRepositoryVisibility<'_> {
fn render(&self) -> Markup {
html! {
@if self.0 == "private" {
dfn title="private" class="visibility" {
"🔒"
}
}
}
}
}
/// Which of a repository's pages is on show, for the tab bar that says so.
///
/// A tab only exists once the page behind it does: there is no value in a
/// tab that leads nowhere.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum RepositoryTab {
Summary,
Tickets,
Patches,
Refs,
Commits,
Compare,
Browse,
Settings,
}
/// The navigation across a repository's own pages, with the current one
/// marked so a visitor can tell where they are in the repository.
pub(super) struct RepoNav<'a> {
pub(super) owner: &'a str,
pub(super) slug: &'a str,
/// The branch the browse tab addresses: the tree page needs a default,
/// and the repository opens on it.
pub(super) default_branch: &'a str,
pub(super) current: RepositoryTab,
pub(super) is_owner: bool,
}
impl Render for RepoNav<'_> {
fn render(&self) -> Markup {
let base = format!("/~{}/{}", self.owner, self.slug);
html! {
nav class="repository-tabs" {
(repository_tab(&base, "Summary", self.current == RepositoryTab::Summary))
(repository_tab(
&format!("{base}/tickets"),
"Tickets",
self.current == RepositoryTab::Tickets,
))
(repository_tab(
&format!("{base}/patches"),
"Patches",
self.current == RepositoryTab::Patches,
))
(repository_tab(&format!("{base}/refs"), "Refs", self.current == RepositoryTab::Refs))
(repository_tab(
&format!("{base}/commits/{}", self.default_branch),
"Commits",
self.current == RepositoryTab::Commits,
))
(repository_tab(
&format!("{base}/compare"),
"Compare",
self.current == RepositoryTab::Compare,
))
(repository_tab(
&format!("{base}/tree/{}", self.default_branch),
"Browse",
self.current == RepositoryTab::Browse,
))
@if self.is_owner {
(repository_tab(
&format!("{base}/settings"),
"Settings",
self.current == RepositoryTab::Settings,
))
}
}
}
}
}
/// A repository's full header: the heading with owner/slug/visibility,
/// followed by the navigation tabs.
///
/// This combines RepositoryHeading and RepoNav for pages that need both,
/// which is most repository pages.
pub(super) struct RepositoryChrome<'a> {
pub(super) owner: &'a str,
pub(super) slug: &'a str,
pub(super) visibility: &'a str,
pub(super) default_branch: &'a str,
pub(super) current_tab: RepositoryTab,
pub(super) is_owner: bool,
}
impl Render for RepositoryChrome<'_> {
fn render(&self) -> Markup {
html! {
(RepositoryHeading {
owner: self.owner,
slug: self.slug,
visibility: self.visibility,
})
(RepoNav {
owner: self.owner,
slug: self.slug,
default_branch: self.default_branch,
current: self.current_tab,
is_owner: self.is_owner,
})
}
}
}
/// A delete form: a POST form with a submit button.
///
/// Used for deleting tickets, SSH keys, and labels. The form and button may
/// carry optional classes for styling. An optional `confirm` message triggers
/// a browser confirmation dialog before submission.
pub(super) struct DeleteForm<'a> {
pub(super) action: String,
pub(super) form_class: Option<&'a str>,
pub(super) button_class: Option<&'a str>,
pub(super) confirm: Option<&'a str>,
}
impl Render for DeleteForm<'_> {
fn render(&self) -> Markup {
let button = if let Some(button_class) = self.button_class {
html! { button type="submit" class=(button_class) { "Delete" } }
} else {
html! { button type="submit" { "Delete" } }
};
match (&self.form_class, self.confirm) {
(Some(fc), Some(msg)) => {
let onsubmit = format!("return confirm('{}');", msg.replace('\'', "\\'"));
html! { form method="post" action=(&self.action) class=(fc) onsubmit=(onsubmit) { (button) } }
}
(Some(fc), None) => {
html! { form method="post" action=(&self.action) class=(fc) { (button) } }
}
(None, Some(msg)) => {
let onsubmit = format!("return confirm('{}');", msg.replace('\'', "\\'"));
html! { form method="post" action=(&self.action) onsubmit=(onsubmit) { (button) } }
}
(None, None) => {
html! { form method="post" action=(&self.action) { (button) } }
}
}
}
}
/// One tab of a repository's navigation.
///
/// `aria-current` is written only when it is true: an attribute present but
/// empty says nothing, and a screen reader would pass over it. The
/// alternative - a conditional attribute in maud - can only carry the
/// attribute's own name as its value, which is not a value `aria-current`
/// understands.
pub(super) fn repository_tab(href: &str, label: &str, current: bool) -> Markup {
if current {
html! { a href=(href) aria-current="page" { (label) } }
} else {
html! { a href=(href) { (label) } }
}
}
/// A reference selection dropdown with branches, tags, and patches.
pub(super) struct RefSelect<'a> {
pub(super) name: &'a str,
pub(super) label: &'a str,
pub(super) branches: &'a [super::repositories::RefSummary],
pub(super) tags: &'a [super::repositories::RefSummary],
pub(super) patches: &'a [super::compare::PatchSummary],
/// The value to mark as selected. If it matches no listed option, a
/// synthetic option is added at the top so the dropdown reflects the
/// current state rather than silently falling back to the first branch.
pub(super) selected: &'a str,
}
impl Render for RefSelect<'_> {
fn render(&self) -> Markup {
let listed = self.branches.iter().any(|b| b.name == self.selected)
|| self.tags.iter().any(|t| t.name == self.selected)
|| self
.patches
.iter()
.any(|p| format!("patches/{}", p.number) == self.selected);
html! {
label {
(self.label)
select name=(self.name) {
@if !listed && !self.selected.is_empty() {
option value=(self.selected) selected { (self.selected) }
}
@if !self.branches.is_empty() {
optgroup label="Branches" {
@for branch in self.branches {
option value=(branch.name) selected[branch.name == self.selected] {
(branch.name)
}
}
}
}
@if !self.tags.is_empty() {
optgroup label="Tags" {
@for tag in self.tags {
option value=(tag.name) selected[tag.name == self.selected] {
(tag.name)
}
}
}
}
@if !self.patches.is_empty() {
optgroup label="Patches" {
@for patch in self.patches {
option value=(format!("patches/{}", patch.number)) selected[format!("patches/{}", patch.number) == self.selected] {
"#" (patch.number) " " (patch.summary)
}
}
}
}
}
}
}
}
}
/// A filter link that can be marked as selected, for ticket/patch filters.
///
/// When selected, adds a "selected" class and aria-current="true".
pub(super) struct FilterLink<'a> {
pub(super) href: &'a str,
pub(super) label: &'a str,
pub(super) base_class: &'a str,
pub(super) selected: bool,
}
impl Render for FilterLink<'_> {
fn render(&self) -> Markup {
if self.selected {
html! {
a class=(format!("{} selected", self.base_class)) href=(self.href) aria-current="true" { (self.label) }
}
} else {
html! { a class=(self.base_class) href=(self.href) { (self.label) } }
}
}
}
/// A reusable pagination component.
///
/// Renders Previous/Next links based on the current page and whether more items exist.
/// The base_url should include the path but not the query string.
pub(super) struct Pagination {
pub current_page: u64,
pub has_next: bool,
pub base_url: String,
}
impl Render for Pagination {
fn render(&self) -> Markup {
html! {
nav class="pagination" {
@if self.current_page > 0 {
a href=(format!("{}?page={}", self.base_url, self.current_page - 1)) class="pagination-prev" { "← Previous" }
}
@if self.has_next {
a href=(format!("{}?page={}", self.base_url, self.current_page + 1)) class="pagination-next" { "Next →" }
}
}
}
}
}
pub struct DateTime {
dt: chrono::DateTime<chrono::FixedOffset>,
}
impl From<&chrono::DateTime<chrono::FixedOffset>> for DateTime {
fn from(value: &chrono::DateTime<chrono::FixedOffset>) -> Self {
Self { dt: value.clone() }
}
}
impl From<&chrono::DateTime<chrono::Utc>> for DateTime {
fn from(value: &chrono::DateTime<chrono::Utc>) -> Self {
Self {
dt: value.fixed_offset(),
}
}
}
impl Render for DateTime {
fn render(&self) -> Markup {
html! {
time datetime=(self.dt.to_rfc3339()) { (self.dt.to_rfc3339()) }
}
}
}