Files
RuDS/crates/bds-ui/src/views/dashboard.rs

428 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use iced::widget::{Space, button, column, container, row, scrollable, text, tooltip};
use iced::{Alignment, Background, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::{t, tw};
use crate::state::tabs::{Tab, TabType};
#[derive(Debug, Clone)]
pub struct DashboardStats {
pub total_posts: usize,
pub published_count: usize,
pub draft_count: usize,
pub archived_count: usize,
pub media_count: usize,
pub image_count: usize,
pub total_media_size: String,
pub tag_count: usize,
pub category_count: usize,
}
#[derive(Debug, Clone)]
pub struct DashboardTimelineMonth {
pub label: String,
pub year: i32,
pub count: usize,
}
#[derive(Debug, Clone)]
pub struct DashboardTag {
pub name: String,
pub count: usize,
pub color: Option<String>,
/// 1122 px, scaled relative to the min/max counts of the displayed tags.
pub font_size: f32,
}
#[derive(Debug, Clone)]
pub struct DashboardCategory {
pub name: String,
pub count: usize,
}
#[derive(Debug, Clone)]
pub struct DashboardRecentPost {
pub post_id: String,
pub title: String,
pub status: String,
pub date: String,
}
/// Dashboard overview state.
#[derive(Debug, Clone)]
pub struct DashboardState {
pub title: String,
pub subtitle: String,
pub stats: DashboardStats,
pub timeline: Vec<DashboardTimelineMonth>,
pub tag_cloud: Vec<DashboardTag>,
pub category_cloud: Vec<DashboardCategory>,
pub recent_posts: Vec<DashboardRecentPost>,
}
impl DashboardState {
pub fn new(project_name: String) -> Self {
Self {
title: t(UiLocale::En, "dashboard.overview"),
subtitle: project_name,
stats: DashboardStats {
total_posts: 0,
published_count: 0,
draft_count: 0,
archived_count: 0,
media_count: 0,
image_count: 0,
total_media_size: "0 B".to_string(),
tag_count: 0,
category_count: 0,
},
timeline: Vec::new(),
tag_cloud: Vec::new(),
category_cloud: Vec::new(),
recent_posts: Vec::new(),
}
}
}
/// Render the dashboard overview.
pub fn view<'a>(state: &'a DashboardState, locale: UiLocale) -> Element<'a, Message> {
let header = text(state.title.clone()).size(24).color(Color::WHITE);
let project_label = text(state.subtitle.clone())
.size(14)
.color(Color::from_rgb(0.6, 0.6, 0.7));
let counts_row = row![
stat_card(
&t(locale, "dashboard.posts"),
state.stats.total_posts.to_string(),
{
let mut details = vec![
format!(
"{} {}",
state.stats.published_count,
t(locale, "dashboard.published")
),
format!(
"{} {}",
state.stats.draft_count,
t(locale, "dashboard.drafts")
),
];
if state.stats.archived_count > 0 {
details.push(format!(
"{} {}",
state.stats.archived_count,
t(locale, "dashboard.archived")
));
}
details
},
),
stat_card(
&t(locale, "dashboard.media"),
state.stats.media_count.to_string(),
vec![
format!(
"{} {}",
state.stats.image_count,
t(locale, "dashboard.images")
),
state.stats.total_media_size.clone(),
],
),
stat_card(
&t(locale, "dashboard.tags"),
state.stats.tag_count.to_string(),
vec![format!(
"{} {}",
state.stats.category_count,
t(locale, "dashboard.categories")
)],
),
]
.spacing(16);
let mut content = column![header, project_label, Space::with_height(16), counts_row].spacing(8);
if !state.timeline.is_empty() {
content = content
.push(Space::with_height(20))
.push(inputs::section_header(&t(locale, "dashboard.timeline")))
.push(timeline_chart(&state.timeline, locale));
}
if !state.tag_cloud.is_empty() {
content = content
.push(Space::with_height(20))
.push(inputs::section_header(&t(locale, "dashboard.tags")))
.push(tag_cloud(&state.tag_cloud, locale));
}
if !state.category_cloud.is_empty() {
content = content
.push(Space::with_height(20))
.push(inputs::section_header(&t(locale, "dashboard.categories")))
.push(category_cloud(&state.category_cloud, locale));
}
if !state.recent_posts.is_empty() {
content = content
.push(Space::with_height(20))
.push(inputs::section_header(&t(locale, "dashboard.recentPosts")))
.push(recent_posts(&state.recent_posts, locale));
}
scrollable(container(content.padding(24).width(Length::Fill)))
.direction(scrollable::Direction::Vertical(inputs::compact_scrollbar()))
.style(inputs::scrollable_style)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a, Message> {
let card_bg = Color::from_rgb(0.15, 0.16, 0.20);
container(
column(
std::iter::once(text(value).size(28).color(Color::WHITE).into())
.chain(std::iter::once(
text(label.to_string())
.size(12)
.color(Color::from_rgb(0.55, 0.58, 0.65))
.into(),
))
.chain(details.into_iter().map(|detail| {
text(detail)
.size(12)
.color(Color::from_rgb(0.72, 0.74, 0.80))
.into()
}))
.collect::<Vec<Element<'a, Message>>>(),
)
.spacing(4)
.align_x(Alignment::Center),
)
.padding(16)
.width(Length::FillPortion(1))
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(card_bg)),
border: iced::Border {
radius: 8.0.into(),
..iced::Border::default()
},
..container::Style::default()
})
.into()
}
fn timeline_chart<'a>(
months: &'a [DashboardTimelineMonth],
_locale: UiLocale,
) -> Element<'a, Message> {
let max_count = months
.iter()
.map(|month| month.count)
.max()
.unwrap_or(1)
.max(1);
row(months
.iter()
.map(|month| {
let height = if month.count == 0 {
8.0
} else {
24.0 + (month.count as f32 / max_count as f32) * 96.0
};
container(
column![
text(month.count.to_string()).size(12).color(Color::WHITE),
container(Space::with_height(height))
.width(Length::Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.25, 0.48, 0.80))),
border: iced::Border {
radius: 6.0.into(),
..iced::Border::default()
},
..container::Style::default()
}),
text(format!("{} {}", month.label, month.year))
.size(11)
.color(Color::from_rgb(0.7, 0.72, 0.78)),
]
.spacing(6)
.align_x(Alignment::Center),
)
.width(Length::FillPortion(1))
.into()
})
.collect::<Vec<_>>())
.spacing(10)
.align_y(Alignment::End)
.into()
}
fn tag_cloud<'a>(tags: &'a [DashboardTag], locale: UiLocale) -> Element<'a, Message> {
let words = tags
.iter()
.map(|tag| {
let bg = parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28));
let fg = contrast_color(bg);
tooltip(
container(text(tag.name.clone()).size(tag.font_size).color(fg))
.padding([6, 10])
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(bg)),
border: iced::Border {
radius: 999.0.into(),
..iced::Border::default()
},
..container::Style::default()
}),
text(post_count_label(locale, tag.count)).size(12),
tooltip::Position::Top,
)
.into()
})
.collect::<Vec<_>>();
row(words).spacing(8).wrap().into()
}
fn category_cloud<'a>(
categories: &'a [DashboardCategory],
locale: UiLocale,
) -> Element<'a, Message> {
let badges = categories
.iter()
.map(|category| {
tooltip(
container(
row![
text(category.name.clone()).size(13).color(Color::WHITE),
text(category.count.to_string())
.size(12)
.color(Color::from_rgb(0.72, 0.74, 0.80)),
]
.spacing(8),
)
.padding([6, 10])
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
border: iced::Border {
radius: 999.0.into(),
..iced::Border::default()
},
..container::Style::default()
}),
text(post_count_label(locale, category.count)).size(12),
tooltip::Position::Top,
)
.into()
})
.collect::<Vec<_>>();
row(badges).spacing(8).wrap().into()
}
fn post_count_label(locale: UiLocale, count: usize) -> String {
tw(
locale,
"dashboard.postCount",
&[("count", &count.to_string())],
)
}
fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Element<'a, Message> {
column(
posts
.iter()
.map(|post| {
container(
row![
button(
column![
text(post.title.clone()).size(14).color(Color::WHITE),
text(post.date.clone())
.size(12)
.color(Color::from_rgb(0.6, 0.62, 0.68)),
]
.spacing(4),
)
.on_press(Message::OpenTab(Tab {
id: post.post_id.clone(),
tab_type: TabType::Post,
title: post.title.clone(),
is_transient: true,
is_dirty: false,
}))
.width(Length::Fill),
status_badge(&post.status),
button(text(t(locale, "dashboard.pin")).size(12))
.on_press(Message::OpenTab(Tab {
id: post.post_id.clone(),
tab_type: TabType::Post,
title: post.title.clone(),
is_transient: false,
is_dirty: false,
}))
.padding([6, 10]),
]
.spacing(12)
.align_y(Alignment::Center),
)
.padding(12)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.13, 0.14, 0.18))),
border: iced::Border {
radius: 8.0.into(),
..iced::Border::default()
},
..container::Style::default()
})
.into()
})
.collect::<Vec<_>>(),
)
.spacing(8)
.into()
}
fn status_badge<'a>(status: &str) -> Element<'a, Message> {
let bg = if status.eq_ignore_ascii_case("published") {
Color::from_rgb(0.16, 0.42, 0.24)
} else {
Color::from_rgb(0.45, 0.33, 0.14)
};
container(text(status.to_string()).size(11).color(Color::WHITE))
.padding([4, 8])
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(bg)),
border: iced::Border {
radius: 999.0.into(),
..iced::Border::default()
},
..container::Style::default()
})
.into()
}
fn parse_color(value: Option<&str>) -> Option<Color> {
let value = value?.trim_start_matches('#');
if value.len() != 6 {
return None;
}
let red = u8::from_str_radix(&value[0..2], 16).ok()?;
let green = u8::from_str_radix(&value[2..4], 16).ok()?;
let blue = u8::from_str_radix(&value[4..6], 16).ok()?;
Some(Color::from_rgb8(red, green, blue))
}
fn contrast_color(background: Color) -> Color {
let luma = 0.299 * background.r + 0.587 * background.g + 0.114 * background.b;
if luma > 0.55 {
Color::BLACK
} else {
Color::WHITE
}
}