15c35c06cdea1711d13d2bf9060bc4610e4b22c2 / 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 single-button POST form: one click to perform an action.
///
/// Covers every place the UI needs a one-shot act: delete a thing, unfollow,
/// react, boost, merge. The variant picks the button's tone; `confirm` wraps
/// it in a `<details>` so destructive acts need a second deliberate click,
/// with no JS involved.
pub(super) struct ActionButton<'a> {
pub(super) action: String,
pub(super) label: &'a str,
pub(super) variant: ActionVariant,
pub(super) hidden: &'a [(&'a str, &'a str)],
pub(super) confirm: Option<&'a str>,
}
pub(super) enum ActionVariant {
/// Neutral bordered button: reactions, unfollow.
Quiet,
/// Green-tinted "on" state: already reacted/boosted.
Active,
/// Red destructive button: delete.
Danger,
/// The standard green submit: merge.
Solid,
}
impl Render for ActionButton<'_> {
fn render(&self) -> Markup {
let variant_class = match self.variant {
ActionVariant::Quiet => "quiet",
ActionVariant::Active => "active",
ActionVariant::Danger => "danger",
ActionVariant::Solid => "solid",
};
let button = html! {
button type="submit" class=(format!("action-button {}", variant_class)) { (self.label) }
};
let hidden_inputs = html! {
@for (name, value) in self.hidden {
input type="hidden" name=(name) value=(value);
}
};
match self.confirm {
Some(msg) => html! {
form method="post" class="action-form" action=(&self.action) {
(hidden_inputs)
details class="confirm" {
summary { (self.label) }
p { (msg) }
(button)
}
}
},
None => html! {
form method="post" class="action-form" action=(&self.action) {
(hidden_inputs)
(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()) }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn action_button_renders_basic_form() {
let button = ActionButton {
action: "/delete/1".to_string(),
label: "Delete",
variant: ActionVariant::Danger,
hidden: &[],
confirm: None,
};
let html = button.render().into_string();
assert!(html.contains(r#"<form method="post" class="action-form" action="/delete/1">"#));
assert!(
html.contains(r#"<button type="submit" class="action-button danger">Delete</button>"#)
);
}
#[test]
fn action_button_renders_hidden_inputs() {
let hidden = [("actor_url", "https://example.com/user")];
let button = ActionButton {
action: "/unfollow".to_string(),
label: "Unfollow",
variant: ActionVariant::Quiet,
hidden: &hidden,
confirm: None,
};
let html = button.render().into_string();
assert!(html.contains(
r#"<input type="hidden" name="actor_url" value="https://example.com/user">"#
));
}
#[test]
fn action_button_escapes_hidden_values() {
let hidden = [("value", r#"test"with"quotes"#)];
let button = ActionButton {
action: "/test".to_string(),
label: "Test",
variant: ActionVariant::Quiet,
hidden: &hidden,
confirm: None,
};
let html = button.render().into_string();
assert!(html.contains(r#"value="test"with"quotes""#));
}
#[test]
fn action_button_renders_confirm_dialog() {
let button = ActionButton {
action: "/delete".to_string(),
label: "Delete",
variant: ActionVariant::Danger,
hidden: &[],
confirm: Some("Delete this item?"),
};
let html = button.render().into_string();
assert!(html.contains("<details class=\"confirm\">"));
assert!(html.contains("<summary>Delete</summary>"));
assert!(html.contains("Delete this item?"));
}
#[test]
fn action_button_variants() {
let quiet = ActionButton {
action: "/test".to_string(),
label: "Quiet",
variant: ActionVariant::Quiet,
hidden: &[],
confirm: None,
};
assert!(quiet.render().into_string().contains("action-button quiet"));
let active = ActionButton {
action: "/test".to_string(),
label: "Active",
variant: ActionVariant::Active,
hidden: &[],
confirm: None,
};
assert!(
active
.render()
.into_string()
.contains("action-button active")
);
let solid = ActionButton {
action: "/test".to_string(),
label: "Solid",
variant: ActionVariant::Solid,
hidden: &[],
confirm: None,
};
assert!(solid.render().into_string().contains("action-button solid"));
}
}