refactor(admin): tidy the admin routes and templates (#2734)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-13 17:38:15 +02:00
1 parent 5afbf67a70
commit 33737e0f79
48 files changed
+697 -679

No files matched your search

@@ -41,17 +41,14 @@ pub fn archives_tab(
}
}
fn status_text(archive: &Archive) -> String {
fn status_text(archive: &Archive) -> &str {
if archive.failed_at.is_some() {
return "Failed".to_owned();
return "Failed";
}
if archive.completed_at.is_some() {
return "Completed".to_owned();
return "Completed";
}
archive
.progress_step
.clone()
.unwrap_or_else(|| "In Progress".to_owned())
archive.progress_step.as_deref().unwrap_or("In Progress")
}
fn archives_table(base: &str, archives: &[Archive]) -> Markup {
@@ -66,9 +66,15 @@ pub fn audit_logs_for_target(
) -> Markup {
let has_previous = current_page > 0;
let offset = (current_page as u64) * (PAGE_SIZE as u64);
let has_next = offset + (entries.len() as u64) < total;
let total_pages = ((total as f64) / (PAGE_SIZE as f64)).ceil().max(1.0) as u64;
let all_logs_href = format!("{base_path}/audit-logs?target_id={target_id}");
let next_page = current_page
.checked_add(1)
.filter(|_| offset + (entries.len() as u64) < total);
let total_pages = total.div_ceil(u64::from(PAGE_SIZE)).max(1);
let page_number = u64::from(current_page) + 1;
let all_logs_href = format!(
"{base_path}/audit-logs?target_id={}",
urlencoding::encode(target_id)
);
html! {
div class="rounded-lg bg-white transition-all border border-neutral-200 p-6" {
@@ -108,7 +114,7 @@ pub fn audit_logs_for_target(
}))
}))
}
@if has_previous || has_next {
@if has_previous || next_page.is_some() {
div class="flex items-center justify-center gap-2" {
@if has_previous {
a href={(tab_href_base) "&audit_logs_page=" (current_page - 1)}
@@ -119,10 +125,10 @@ pub fn audit_logs_for_target(
}
}
span class="text-sm text-neutral-500" {
"Page " (current_page + 1) " of " (total_pages)
"Page " (page_number) " of " (total_pages)
}
@if has_next {
a href={(tab_href_base) "&audit_logs_page=" (current_page + 1)}
@if let Some(page) = next_page {
a href={(tab_href_base) "&audit_logs_page=" (page)}
class="inline-flex items-center justify-center gap-2 font-medium \
rounded-lg bg-neutral-50 text-neutral-700 border \
border-neutral-300 px-3 py-1.5 text-sm" {
+9 -14
View File
@@ -81,20 +81,15 @@ pub fn guild_asset_url(
}
pub fn initials(name: &str) -> String {
let parts = name
.split_whitespace()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
match parts.as_slice() {
[] => "?".to_owned(),
[part] => part.chars().next().unwrap_or('?').to_uppercase().collect(),
[first, .., last] => {
let mut value = String::new();
value.extend(first.chars().next().unwrap_or('?').to_uppercase());
value.extend(last.chars().next().unwrap_or('?').to_uppercase());
value
}
}
let mut parts = name.split_whitespace();
let Some(first) = parts.next() else {
return "?".to_owned();
};
std::iter::once(first)
.chain(parts.next_back())
.flat_map(|part| part.chars().take(1))
.flat_map(char::to_uppercase)
.collect()
}
fn media_asset_url(
@@ -45,8 +45,8 @@ fn ncmec_badge(att: &Attachment) -> Markup {
}
fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
let images: Vec<&Attachment> = msg.attachments.iter().filter(|a| is_image(a)).collect();
if images.is_empty() {
let mut images = msg.attachments.iter().filter(|a| is_image(a)).peekable();
if images.peek().is_none() {
return html! {};
}
let spacer = if !msg.content.is_empty() {
@@ -56,7 +56,7 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
};
html! {
div class=(spacer) {
@for att in &images {
@for att in images {
div class="max-w-xl overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50" {
a href=(att.url) target="_blank" rel="noopener noreferrer"
class="block overflow-hidden bg-neutral-100" {
@@ -116,8 +116,8 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
}
fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Markup {
let others: Vec<&Attachment> = msg.attachments.iter().filter(|a| !is_image(a)).collect();
if others.is_empty() {
let mut others = msg.attachments.iter().filter(|a| !is_image(a)).peekable();
if others.peek().is_none() {
return html! {};
}
let spacer = if has_content_or_images {
@@ -127,7 +127,7 @@ fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Marku
};
html! {
div class=(spacer) {
@for att in &others {
@for att in others {
div class="flex flex-wrap items-center gap-2 text-xs" {
(paperclip_icon("text-neutral-400"))
a href=(att.url) target="_blank" rel="noopener noreferrer"
@@ -17,7 +17,6 @@ pub mod message_data;
pub mod message_list;
pub mod nsfw_indicators;
pub mod page_container;
pub mod pagination;
pub mod resource_link;
pub mod section_card;
pub mod stack;
@@ -19,7 +19,7 @@ pub fn adult_content_badge(is_adult: bool, label: Option<&str>) -> Markup {
}
fn truncate(text: &str, max: usize) -> String {
if text.len() <= max {
if text.chars().nth(max).is_none() {
text.to_owned()
} else {
let boundary = max.saturating_sub(1);
@@ -29,10 +29,7 @@ fn truncate(text: &str, max: usize) -> String {
}
pub fn content_warning_badge(level: Option<i32>, text: Option<&str>, inline_text: bool) -> Markup {
let Some(lvl) = level else {
return html! {};
};
if lvl != CONTENT_WARNING_LEVEL {
if level != Some(CONTENT_WARNING_LEVEL) {
return html! {};
}
let default_text = "This contains sensitive content.";
@@ -82,13 +79,8 @@ fn resolve_nsfw_source(
fn source_label(source: NsfwSource, explicit: Option<bool>) -> &'static str {
match source {
NsfwSource::Channel => {
if explicit == Some(true) {
"(override on)"
} else {
"(override off)"
}
}
NsfwSource::Channel if explicit == Some(true) => "(override on)",
NsfwSource::Channel => "(override off)",
NsfwSource::Category => "(from category)",
NsfwSource::Community => "(from community)",
NsfwSource::None => "",
@@ -110,11 +102,7 @@ pub fn channel_nsfw_state_badge(
}
let has_context =
nsfw_override.is_some() || category_override.is_some() || guild_nsfw.is_some();
let (source, explicit) = if has_context {
resolve_nsfw_source(nsfw_override, category_override, guild_nsfw)
} else {
(NsfwSource::None, None)
};
let (source, explicit) = resolve_nsfw_source(nsfw_override, category_override, guild_nsfw);
html! {
span class="inline-flex flex-wrap items-center gap-1" {
@if is_nsfw {
@@ -1,34 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use maud::{Markup, PreEscaped, html};
pub fn pagination(base_url: &str, current_page: u32, has_more: bool, extra_params: &str) -> Markup {
let sep = if base_url.contains('?') { "&" } else { "?" };
let has_previous = current_page > 1;
html! {
div class="mt-4 flex items-center justify-between" {
@if has_previous {
p class="text-sm font-normal text-neutral-500" {
a href={
(base_url) (sep) "page=" (current_page - 1) (extra_params)
} class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 hover:text-neutral-900" {
(PreEscaped("&larr; Previous"))
}
}
} @else {
span {}
}
@if has_more {
p class="text-sm font-normal text-neutral-500" {
a href={
(base_url) (sep) "page=" (current_page + 1) (extra_params)
} class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 hover:text-neutral-900" {
(PreEscaped("Next &rarr;"))
}
}
} @else {
span {}
}
}
}
}
@@ -12,8 +12,6 @@ const RESOURCE_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral
hover:text-neutral-600 hover:decoration-neutral-500 text-sm";
const NAV_LINK_CLASS: &str = "label rounded-lg border border-neutral-300 bg-white \
px-3 py-2 text-neutral-700 transition-colors hover:bg-neutral-50";
const TEXT_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral-300 \
hover:text-neutral-600 hover:decoration-neutral-500";
impl ResourceType {
fn path_segment(self) -> &'static str {
@@ -91,18 +89,3 @@ pub fn nav_link(href: &str, content: Markup) -> Markup {
a href=(href) class=(NAV_LINK_CLASS) { (content) }
}
}
pub fn text_link(href: &str, content: Markup, external: bool, _mono: bool) -> Markup {
if external {
html! {
a href=(href) class=(TEXT_LINK_CLASS)
target="_blank" rel="noopener noreferrer" {
(content)
}
}
} else {
html! {
a href=(href) class=(TEXT_LINK_CLASS) { (content) }
}
}
}
+25 -25
View File
@@ -72,27 +72,22 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
}
fn build_pagination_url(base: &str, page: u32, params: &AuditLogsParams<'_>) -> String {
let mut url = format!("{base}/audit-logs?page={page}");
if !params.query.is_empty() {
url.push_str(&format!("&q={}", params.query));
}
if !params.admin_user_id.is_empty() {
url.push_str(&format!("&admin_user_id={}", params.admin_user_id));
}
if !params.target_id.is_empty() {
url.push_str(&format!("&target_id={}", params.target_id));
}
if !params.target_type.is_empty() {
url.push_str(&format!("&target_type={}", params.target_type));
}
if !params.sort_by.is_empty() {
url.push_str(&format!("&sort_by={}", params.sort_by));
}
if !params.sort_order.is_empty() {
url.push_str(&format!("&sort_order={}", params.sort_order));
}
url.push_str(&format!("&limit={}", params.limit));
url
let mut query = url::form_urlencoded::Serializer::new(String::new());
query.append_pair("page", &page.to_string());
query.extend_pairs(
[
("q", params.query),
("admin_user_id", params.admin_user_id),
("target_id", params.target_id),
("target_type", params.target_type),
("sort_by", params.sort_by),
("sort_order", params.sort_order),
]
.into_iter()
.filter(|(_, value)| !value.is_empty()),
);
query.append_pair("limit", &params.limit.to_string());
format!("{base}/audit-logs?{}", query.finish())
}
pub fn audit_logs_page(
@@ -107,10 +102,15 @@ pub fn audit_logs_page(
let total = data.total;
let entries = &data.logs;
let total_pages = if params.limit > 0 {
((total as f64) / (params.limit as f64)).ceil().max(1.0) as u64
total.div_ceil(u64::from(params.limit)).max(1)
} else {
1
};
let page_number = u64::from(params.current_page) + 1;
let next_page = params
.current_page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
let showing = format!("Showing {} of {} entries", entries.len(), total);
html! {
(page_header_with_actions("Audit Logs", None, html! {
@@ -134,10 +134,10 @@ pub fn audit_logs_page(
}
} @else { span {} }
span class="text-sm text-neutral-500" {
"Page " (params.current_page + 1) " of " (total_pages)
"Page " (page_number) " of " (total_pages)
}
@if (params.current_page + 1) < total_pages as u32 {
a href=(build_pagination_url(base, params.current_page + 1, params))
@if let Some(page) = next_page {
a href=(build_pagination_url(base, page, params))
class="text-sm text-neutral-900 underline" {
(PreEscaped("Next &rarr;"))
}
@@ -14,12 +14,7 @@ use crate::{
use maud::{Markup, html};
pub fn format_action(action: &str) -> String {
let replaced = action.replace('_', " ");
let mut chars = replaced.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
capitalise(&action.replace('_', " "))
}
pub fn action_badge_variant(action: &str) -> BadgeVariant {
@@ -92,10 +87,8 @@ pub fn admin_user_cell(base: &str, entry: &AuditLogEntry) -> Markup {
}
}
fn target_guild_label(guild: Option<&AuditLogGuildSummary>) -> String {
guild
.map(|guild| guild.name.clone())
.unwrap_or_else(|| "Guild".to_owned())
fn target_guild_label(guild: Option<&AuditLogGuildSummary>) -> &str {
guild.map(|guild| guild.name.as_str()).unwrap_or("Guild")
}
pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
@@ -16,7 +16,7 @@ use crate::{
};
use maud::{Markup, html};
const MAX_GIFT_CODES: u32 = 100;
pub const MAX_GIFT_CODES: u32 = 100;
const DEFAULT_GIFT_COUNT: u32 = 10;
pub fn gift_codes_page(
@@ -44,12 +44,12 @@ pub fn gift_codes_page(
(csrf_input(csrf_token))
div class="flex flex-col gap-4" {
(form_field_group(
"Number of codes", "gift-count-slider", false, None,
"Number of codes", "gift-count-slider", true, None,
Some(&format!("Range: 1-{MAX_GIFT_CODES}")),
html! {
input type="number" id="gift-count-slider" name="count"
value=(DEFAULT_GIFT_COUNT) min="1"
max=(MAX_GIFT_CODES)
max=(MAX_GIFT_CODES) required
class=(FORM_INPUT_CLASS);
},
))
@@ -25,7 +25,9 @@ pub fn members_tab(
let total = response.total;
let total_pages = if limit > 0 { total.div_ceil(limit) } else { 1 };
let has_previous = page > 0;
let has_next = (page as u64) < total_pages.saturating_sub(1);
let next_page = page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
html! {
div class="space-y-4" {
@@ -34,12 +36,16 @@ pub fn members_tab(
"Guild Members (" (total) ")"
}
p class="text-sm text-neutral-500" {
@let start = response.offset + 1;
@let end = std::cmp::min(
response.offset + response.members.len() as u64,
total,
);
"Showing " (start) "-" (end) " of " (total)
@if response.members.is_empty() {
"Showing 0 of " (total)
} @else {
@let start = u128::from(response.offset) + 1;
@let end = std::cmp::min(
u128::from(response.offset) + response.members.len() as u128,
u128::from(total),
);
"Showing " (start) "-" (end) " of " (total)
}
}
}
@@ -70,10 +76,10 @@ pub fn members_tab(
}
}
p class="text-sm text-neutral-500" {
"Page " (page + 1) " of " (total_pages)
"Page " (u64::from(page) + 1) " of " (total_pages)
}
@if has_next {
a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (page + 1)}
@if let Some(next_page) = next_page {
a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (next_page)}
class="inline-flex items-center rounded-md bg-brand-primary px-3 \
py-2 text-sm font-medium text-white hover:bg-brand-primary-dark" {
"Next \u{2192}"
@@ -40,13 +40,13 @@ fn channel_type_label(channel_type: i32) -> &'static str {
pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &str) -> Markup {
let base = &config.base_path;
let mut sorted_channels = guild.channels.clone();
let mut sorted_channels: Vec<_> = guild.channels.iter().collect();
sorted_channels.sort_by_key(|c| c.position);
let channels_by_id: std::collections::HashMap<&str, &crate::api::types::GuildChannelSummary> =
guild.channels.iter().map(|c| (c.id.as_str(), c)).collect();
let mut sorted_roles = guild.roles.clone();
let mut sorted_roles: Vec<_> = guild.roles.iter().collect();
sorted_roles.sort_by_key(|role| std::cmp::Reverse(role.position));
let icon_url = guild_icon_url(config, &guild.id, guild.icon.as_deref(), 256, true);
@@ -102,16 +102,10 @@ pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &
p class="text-sm font-semibold text-neutral-500" {
"Custom warning text"
}
@if let Some(ref text) = guild.content_warning_text {
@if !text.trim().is_empty() {
blockquote class="border-amber-300 border-l-2 bg-amber-50 \
px-3 py-2 text-neutral-800 text-sm italic" {
(text)
}
} @else {
p class="text-sm text-neutral-500" {
"\u{2014} (default fallback shown to users)"
}
@if let Some(text) = guild.content_warning_text.as_deref().filter(|text| !text.trim().is_empty()) {
blockquote class="border-amber-300 border-l-2 bg-amber-50 \
px-3 py-2 text-neutral-800 text-sm italic" {
(text)
}
} @else {
p class="text-sm text-neutral-500" {
@@ -17,8 +17,9 @@ pub fn reports_tab(
let base = &config.base_path;
let page_size: u64 = 25;
let has_previous = page > 0;
let has_next = (page as u64) * page_size + reports.len() as u64 > 0
&& (page as u64 + 1) * page_size < total;
let next_page = page.checked_add(1).filter(|next_page| {
(page > 0 || !reports.is_empty()) && u64::from(*next_page) * page_size < total
});
html! {
div class="space-y-4" {
div class="flex items-center justify-between" {
@@ -48,7 +49,7 @@ pub fn reports_tab(
))
}
@if has_previous || has_next {
@if has_previous || next_page.is_some() {
div class="flex justify-center gap-2" {
@if has_previous {
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page - 1)}
@@ -58,8 +59,8 @@ pub fn reports_tab(
"\u{2190} Newer"
}
}
@if has_next {
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page + 1)}
@if let Some(next_page) = next_page {
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (next_page)}
class="inline-flex items-center rounded-md border border-neutral-300 \
bg-white px-3 py-2 text-sm font-medium text-neutral-700 \
hover:bg-neutral-50" {
@@ -64,7 +64,6 @@ impl GuildsListParams {
pub struct GuildsListResults<'a> {
pub guilds: Option<&'a [GuildInfo]>,
pub total: Option<u64>,
pub has_more: bool,
}
pub fn guilds_list_page(
@@ -73,14 +72,12 @@ pub fn guilds_list_page(
params: &GuildsListParams,
results: Option<&[GuildInfo]>,
total: Option<u64>,
has_more: bool,
is_htmx: bool,
) -> Markup {
let base = &config.base_path;
let result_state = GuildsListResults {
guilds: results,
total,
has_more,
};
let has_results = results.is_some_and(|guilds| !guilds.is_empty());
let header = html! {
@@ -164,7 +161,10 @@ fn render_results(
fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Markup {
let total_pages = total.div_ceil(u64::from(params.limit)).max(1);
let has_previous = params.page > 0;
let has_next = u64::from(params.page) < total_pages.saturating_sub(1);
let next_page = params
.page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
html! {
div class="mt-6 flex items-center justify-center gap-3" {
@if has_previous {
@@ -181,10 +181,10 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma
}
}
span class="text-neutral-600 text-sm" {
"Page " (params.page + 1) " of " (total_pages)
"Page " (u64::from(params.page) + 1) " of " (total_pages)
}
@if has_next {
a href=(pagination_url(&config.base_path, params, params.page + 1))
@if let Some(next_page) = next_page {
a href=(pagination_url(&config.base_path, params, next_page))
class="rounded-lg bg-neutral-900 px-6 py-2 font-medium text-sm \
text-white no-underline transition-colors hover:bg-neutral-800" {
"Next \u{2192}"
@@ -200,8 +200,7 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma
}
fn pagination_url(base: &str, params: &GuildsListParams, page: u32) -> String {
let mut parts = Vec::new();
parts.push(format!("page={page}"));
let mut parts = vec![format!("page={page}"), format!("limit={}", params.limit)];
if !params.q.is_empty() {
parts.push(format!("q={}", urlencoding::encode(&params.q)));
}
+2 -13
View File
@@ -7,7 +7,6 @@ use crate::{
templates::{
components::{
auto_refresh::auto_refresh,
badge::{BadgeVariant, badge},
data_field::{data_field_mono, data_field_text, data_grid},
form::{csrf_input, danger_button, form_field_group},
page_container::{page_header_with_actions, page_header_with_back},
@@ -18,17 +17,7 @@ use crate::{
};
use maud::{Markup, html};
fn status_badge(status: &str) -> Markup {
let variant = match status {
"queued" => BadgeVariant::Default,
"running" => BadgeVariant::Info,
"succeeded" => BadgeVariant::Success,
"failed" | "deadletter" => BadgeVariant::Danger,
"cancelled" => BadgeVariant::Warning,
_ => BadgeVariant::Default,
};
badge(status, variant)
}
use super::jobs_list_helpers::status_badge;
fn val_str<'a>(job: &'a serde_json::Value, key: &str) -> &'a str {
job.get(key).and_then(|v| v.as_str()).unwrap_or("")
@@ -71,7 +60,7 @@ fn progress_bar(job: &serde_json::Value) -> Markup {
}
(cur, Some(tot)) => {
let c = cur.unwrap_or(0);
let pct = (c * 100 / tot).min(100);
let pct = (u128::from(c) * 100 / u128::from(tot)).min(100);
html! {
div role="progressbar" aria-valuenow=(pct) aria-valuemin="0"
aria-valuemax="100"
@@ -33,25 +33,26 @@ pub(crate) fn format_progress(job: &serde_json::Value) -> String {
(Some(cur), None | Some(0)) => format!("{cur}"),
(cur, Some(tot)) => {
let c = cur.unwrap_or(0);
let pct = c.saturating_mul(100).checked_div(tot).unwrap_or(0);
let pct = (u128::from(c) * 100)
.checked_div(u128::from(tot))
.unwrap_or(0);
format!("{c} / {tot} ({pct}%)")
}
}
}
pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
let job_id = job
.get("job_id")
.and_then(|v| v.as_str().or_else(|| v.as_u64().map(|_| "")))
.unwrap_or("");
let job_id_display = job
fn job_id_text(value: &serde_json::Value) -> String {
value
.get("job_id")
.map(|v| match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
_ => v.to_string(),
})
.unwrap_or_default();
.unwrap_or_default()
}
pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
let job_id = job_id_text(job);
let task_type = job
.get("task_type")
.and_then(|v| v.as_str())
@@ -79,23 +80,17 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
.unwrap_or_else(|| "cron".to_owned());
let progress = format_progress(job);
let link_id = if job_id.is_empty() {
&job_id_display
} else {
job_id
};
table_row(html! {
(table_cell(true, html! {
span class="whitespace-nowrap text-sm" { (created_at) }
}))
(table_cell(false, html! {
a href={(base) "/jobs/" (link_id)}
a href={(base) "/jobs/" (job_id)}
hx-target="#main-content"
hx-swap="innerHTML"
hx-push-url="true"
class="text-blue-600 text-sm hover:underline" {
(job_id_display)
(job_id)
}
}))
(table_cell(false, html! {
@@ -103,7 +98,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
}))
(table_cell(false, html! { (status_badge(status)) }))
(table_cell(false, html! {
span class="text-sm" data-job-progress=(link_id) { (progress) }
span class="text-sm" data-job-progress=(job_id) { (progress) }
}))
(table_cell(true, html! {
span class="text-sm" { (attempts) "/" (max_attempts) }
@@ -112,7 +107,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
span class="text-sm" { (requester) }
}))
(table_cell(false, html! {
a href={(base) "/jobs/" (link_id)}
a href={(base) "/jobs/" (job_id)}
hx-target="#main-content"
hx-swap="innerHTML"
hx-push-url="true"
@@ -159,14 +154,7 @@ pub(crate) fn next_page_link(
.get("created_at")
.and_then(|v| v.as_str())
.unwrap_or("");
let job_id = cursor
.get("job_id")
.map(|v| match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
_ => v.to_string(),
})
.unwrap_or_default();
let job_id = job_id_text(cursor);
let mut params = vec![
format!("cursor_bucket_day={bucket_day}"),
@@ -466,7 +466,10 @@ fn reports_pagination(
limit: u32,
total: u64,
) -> Markup {
let total_pages = ((total + u64::from(limit).saturating_sub(1)) / u64::from(limit)).max(1);
let total_pages = total.div_ceil(u64::from(limit)).max(1);
let next_page = page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
html! {
div class="mt-4 flex items-center justify-between" {
@if page > 0 {
@@ -478,10 +481,10 @@ fn reports_pagination(
span {}
}
span class="text-neutral-500 text-sm" {
"Page " (page + 1) " of " (total_pages)
"Page " (u64::from(page) + 1) " of " (total_pages)
}
@if u64::from(page + 1) < total_pages {
a href=(reports_url(config, filters, page + 1, limit))
@if let Some(next_page) = next_page {
a href=(reports_url(config, filters, next_page, limit))
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
(PreEscaped("Next &rarr;"))
}
@@ -121,7 +121,7 @@ fn status_section(base: &str, rs: &RefreshStatus) -> Markup {
@if is_in_progress {
@if let (Some(idx), Some(tot)) = (rs.indexed, rs.total) {
@let pct = idx.saturating_mul(100).checked_div(tot).unwrap_or(0);
@let pct = (u128::from(idx) * 100).checked_div(u128::from(tot)).unwrap_or(0);
div class="space-y-2" {
div class="flex items-center justify-between" {
p class="text-sm text-neutral-700" {
@@ -146,7 +146,7 @@ fn deletion_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
(csrf_input(csrf_token))
div class="space-y-3" {
(form_label("Days until deletion"))
input type="number" name="days" value="60"
input type="number" name="days_until_deletion" value="60"
min="60" max="365"
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
@@ -322,9 +322,8 @@ fn message_shred_status_content(status: &serde_json::Value) -> Markup {
let total = value_u64(status, "total").unwrap_or(0);
let processed = value_u64(status, "processed").unwrap_or(0);
let skipped = value_u64(status, "skipped").unwrap_or(0);
let percentage = processed
.saturating_mul(100)
.checked_div(total)
let percentage = (u128::from(processed) * 100)
.checked_div(u128::from(total))
.unwrap_or(0)
.min(100);
html! {
@@ -78,9 +78,11 @@ fn report_section(
}
};
let offset = current_page as u64 * limit as u64;
let offset = u64::from(current_page) * u64::from(limit);
let has_previous = current_page > 0;
let has_next = offset + (reports.len() as u64) < total;
let next_page = current_page
.checked_add(1)
.filter(|_| (reports.len() as u64) < total.saturating_sub(offset));
let (sent_page, received_page) = if kind == "sent" {
(current_page, other_page)
@@ -115,7 +117,7 @@ fn report_section(
))
}
@if has_previous || has_next {
@if has_previous || next_page.is_some() {
div class="flex justify-center gap-2" {
@if has_previous {
a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { current_page - 1 } else { sent_page }) "&reports_received_page=" (if kind == "received" { current_page - 1 } else { received_page })}
@@ -125,8 +127,8 @@ fn report_section(
"Previous"
}
}
@if has_next {
a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { current_page + 1 } else { sent_page }) "&reports_received_page=" (if kind == "received" { current_page + 1 } else { received_page })}
@if let Some(next_page) = next_page {
a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { next_page } else { sent_page }) "&reports_received_page=" (if kind == "received" { next_page } else { received_page })}
class="inline-flex items-center rounded-md border \
border-neutral-300 bg-white px-3 py-2 text-sm \
font-medium text-neutral-700 hover:bg-neutral-50" {
@@ -393,6 +393,7 @@ fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email:
}
fn pagination_controls(base: &str, params: &UserListParams, has_more: bool) -> Markup {
let next_page = params.page.checked_add(1).filter(|_| has_more);
html! {
div class="mt-4 flex items-center justify-between" {
@if params.page > 0 {
@@ -403,8 +404,8 @@ fn pagination_controls(base: &str, params: &UserListParams, has_more: bool) -> M
} @else {
span {}
}
@if has_more {
a href=(users_url(base, params, params.page + 1))
@if let Some(next_page) = next_page {
a href=(users_url(base, params, next_page))
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
"Next >"
}