use crate::config::Theme;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pagination {
pub page: usize,
pub total_pages: usize,
pub per_page: usize,
pub total_items: usize,
pub has_prev: bool,
pub has_next: bool,
pub start_item: usize,
pub end_item: usize,
}
impl Pagination {
pub fn new(page: usize, per_page: usize, total_items: usize) -> Self {
let total_pages = (total_items + per_page - 1) / per_page;
let page = page.min(total_pages).max(1);
let start_item = (page - 1) * per_page + 1;
let end_item = (start_item + per_page - 1).min(total_items);
Self {
page,
total_pages,
per_page,
total_items,
has_prev: page > 1,
has_next: page < total_pages,
start_item: if total_items > 0 { start_item } else { 0 },
end_item,
}
}
pub fn page_numbers(&self, window: usize) -> Vec<PageNumber> {
let mut pages = Vec::new();
if self.total_pages <= 0 {
return pages;
}
pages.push(PageNumber::Page(1));
let start = (self.page as i64 - window as i64).max(2) as usize;
let end = (self.page + window).min(self.total_pages - 1);
if start > 2 {
pages.push(PageNumber::Ellipsis);
}
for p in start..=end {
pages.push(PageNumber::Page(p));
}
if end < self.total_pages - 1 {
pages.push(PageNumber::Ellipsis);
}
if self.total_pages > 1 {
pages.push(PageNumber::Page(self.total_pages));
}
pages
}
}
#[derive(Debug, Clone, Copy)]
pub enum PageNumber {
Page(usize),
Ellipsis,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlashMessage {
pub level: MessageLevel,
pub message: String,
pub dismiss_after: Option<u32>,
}
impl FlashMessage {
pub fn success(message: impl Into<String>) -> Self {
Self {
level: MessageLevel::Success,
message: message.into(),
dismiss_after: Some(5),
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
level: MessageLevel::Error,
message: message.into(),
dismiss_after: None,
}
}
pub fn warning(message: impl Into<String>) -> Self {
Self {
level: MessageLevel::Warning,
message: message.into(),
dismiss_after: Some(10),
}
}
pub fn info(message: impl Into<String>) -> Self {
Self {
level: MessageLevel::Info,
message: message.into(),
dismiss_after: Some(5),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageLevel {
Success,
Error,
Warning,
Info,
}
impl MessageLevel {
pub fn css_class(&self) -> &'static str {
match self {
Self::Success => "alert-success",
Self::Error => "alert-error",
Self::Warning => "alert-warning",
Self::Info => "alert-info",
}
}
pub fn icon(&self) -> &'static str {
match self {
Self::Success => "check-circle",
Self::Error => "x-circle",
Self::Warning => "alert-triangle",
Self::Info => "info",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Breadcrumb {
pub label: String,
pub url: Option<String>,
pub icon: Option<String>,
}
impl Breadcrumb {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
url: None,
icon: None,
}
}
pub fn url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableColumn {
pub field: String,
pub label: String,
pub sortable: bool,
pub sort_direction: Option<SortDirection>,
pub css_class: Option<String>,
pub width: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SortDirection {
Asc,
Desc,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableRow {
pub id: String,
pub cells: Vec<TableCell>,
pub selected: bool,
pub css_class: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableCell {
pub field: String,
pub value: serde_json::Value,
pub rendered: String,
pub cell_type: CellType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CellType {
Text,
Number,
Boolean,
Date,
DateTime,
Email,
Url,
Image,
Badge,
Actions,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterDef {
pub field: String,
pub label: String,
pub filter_type: FilterType,
pub choices: Vec<FilterChoice>,
pub current: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FilterType {
Boolean,
Select,
DateRange,
NumberRange,
Text,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterChoice {
pub value: String,
pub label: String,
pub count: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatCard {
pub title: String,
pub value: String,
pub change: Option<StatChange>,
pub icon: Option<String>,
pub color: Option<String>,
pub link: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatChange {
pub value: String,
pub positive: bool,
pub period: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuickAction {
pub label: String,
pub url: String,
pub icon: Option<String>,
pub css_class: Option<String>,
}
pub fn generate_admin_css(theme: &Theme) -> String {
let variables = theme.to_css_variables();
format!(
r#"{}
/* Admin Base Styles */
* {{
box-sizing: border-box;
margin: 0;
padding: 0;
}}
body {{
font-family: var(--admin-font);
background: var(--admin-bg);
color: var(--admin-text);
line-height: 1.5;
}}
/* Layout */
.admin-layout {{
display: flex;
min-height: 100vh;
}}
.admin-sidebar {{
width: var(--admin-sidebar-width);
background: var(--admin-surface);
border-right: 1px solid var(--admin-border);
display: flex;
flex-direction: column;
}}
.admin-content {{
flex: 1;
overflow-x: auto;
}}
/* Navigation */
.admin-nav {{
padding: 1rem;
}}
.admin-nav-item {{
display: flex;
align-items: center;
padding: 0.75rem 1rem;
color: var(--admin-text-muted);
text-decoration: none;
border-radius: var(--admin-radius);
transition: all 0.15s;
}}
.admin-nav-item:hover,
.admin-nav-item.active {{
background: var(--admin-primary);
color: white;
}}
/* Cards */
.admin-card {{
background: var(--admin-surface);
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius);
padding: 1.5rem;
}}
/* Tables */
.admin-table {{
width: 100%;
border-collapse: collapse;
}}
.admin-table th,
.admin-table td {{
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--admin-border);
}}
.admin-table th {{
font-weight: 600;
color: var(--admin-text-muted);
font-size: 0.875rem;
}}
.admin-table tr:hover {{
background: rgba(255, 255, 255, 0.02);
}}
/* Forms */
.admin-input {{
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--admin-bg);
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius);
color: var(--admin-text);
font-size: 0.875rem;
}}
.admin-input:focus {{
outline: none;
border-color: var(--admin-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}}
/* Buttons */
.admin-btn {{
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--admin-radius);
border: none;
cursor: pointer;
transition: all 0.15s;
}}
.admin-btn-primary {{
background: var(--admin-primary);
color: white;
}}
.admin-btn-primary:hover {{
filter: brightness(1.1);
}}
.admin-btn-danger {{
background: var(--admin-error);
color: white;
}}
/* Alerts */
.admin-alert {{
padding: 1rem;
border-radius: var(--admin-radius);
margin-bottom: 1rem;
}}
.alert-success {{
background: rgba(34, 197, 94, 0.1);
border: 1px solid var(--admin-success);
color: var(--admin-success);
}}
.alert-error {{
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--admin-error);
color: var(--admin-error);
}}
/* Badges */
.admin-badge {{
display: inline-flex;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
}}
.badge-success {{
background: rgba(34, 197, 94, 0.2);
color: var(--admin-success);
}}
.badge-warning {{
background: rgba(245, 158, 11, 0.2);
color: var(--admin-warning);
}}
.badge-error {{
background: rgba(239, 68, 68, 0.2);
color: var(--admin-error);
}}
/* Pagination */
.admin-pagination {{
display: flex;
align-items: center;
gap: 0.25rem;
}}
.admin-page-btn {{
min-width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--admin-radius);
border: 1px solid var(--admin-border);
background: transparent;
color: var(--admin-text);
cursor: pointer;
}}
.admin-page-btn.active {{
background: var(--admin-primary);
border-color: var(--admin-primary);
color: white;
}}
"#,
variables
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pagination() {
let pagination = Pagination::new(1, 10, 95);
assert_eq!(pagination.total_pages, 10);
assert!(pagination.has_next);
assert!(!pagination.has_prev);
assert_eq!(pagination.start_item, 1);
assert_eq!(pagination.end_item, 10);
}
#[test]
fn test_pagination_empty() {
let pagination = Pagination::new(1, 10, 0);
assert_eq!(pagination.total_pages, 0);
assert!(!pagination.has_next);
assert!(!pagination.has_prev);
assert_eq!(pagination.start_item, 0);
}
#[test]
fn test_flash_message() {
let msg = FlashMessage::success("Record saved");
assert_eq!(msg.level, MessageLevel::Success);
assert_eq!(msg.dismiss_after, Some(5));
}
}