fix: dashboard now in as well as settings
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use iced::widget::{column, container, row, text, Space};
|
||||
use iced::widget::{button, column, container, row, scrollable, text, Space};
|
||||
use iced::{Alignment, Background, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -6,29 +6,83 @@ use bds_core::i18n::UiLocale;
|
||||
use crate::app::Message;
|
||||
use crate::components::inputs;
|
||||
use crate::i18n::t;
|
||||
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>,
|
||||
}
|
||||
|
||||
#[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 post_count: usize,
|
||||
pub media_count: usize,
|
||||
pub template_count: usize,
|
||||
pub script_count: usize,
|
||||
pub draft_count: usize,
|
||||
pub published_count: usize,
|
||||
pub project_name: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub stats: DashboardStats,
|
||||
pub timeline: Vec<DashboardTimelineMonth>,
|
||||
pub tag_cloud: Vec<DashboardTag>,
|
||||
pub tag_overflow_count: usize,
|
||||
pub category_cloud: Vec<DashboardCategory>,
|
||||
pub recent_posts: Vec<DashboardRecentPost>,
|
||||
}
|
||||
|
||||
impl DashboardState {
|
||||
pub fn new(project_name: String) -> Self {
|
||||
Self {
|
||||
post_count: 0,
|
||||
media_count: 0,
|
||||
template_count: 0,
|
||||
script_count: 0,
|
||||
draft_count: 0,
|
||||
published_count: 0,
|
||||
project_name,
|
||||
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(),
|
||||
tag_overflow_count: 0,
|
||||
category_cloud: Vec::new(),
|
||||
recent_posts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,55 +92,89 @@ pub fn view<'a>(
|
||||
state: &'a DashboardState,
|
||||
locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
let header = text(t(locale, "dashboard.overview"))
|
||||
.size(20)
|
||||
let header = text(state.title.clone())
|
||||
.size(24)
|
||||
.color(Color::WHITE);
|
||||
|
||||
let project_label = text(state.project_name.clone())
|
||||
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.post_count),
|
||||
stat_card(&t(locale, "dashboard.media"), state.media_count),
|
||||
stat_card(&t(locale, "dashboard.templates"), state.template_count),
|
||||
stat_card(&t(locale, "dashboard.scripts"), state.script_count),
|
||||
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 status_row = row![
|
||||
stat_card(&t(locale, "dashboard.drafts"), state.draft_count),
|
||||
stat_card(&t(locale, "dashboard.published"), state.published_count),
|
||||
]
|
||||
.spacing(16);
|
||||
let timeline = timeline_chart(&state.timeline, locale);
|
||||
let tags = tag_cloud(&state.tag_cloud, state.tag_overflow_count, locale);
|
||||
let categories = category_cloud(&state.category_cloud, locale);
|
||||
let recent = recent_posts(&state.recent_posts, locale);
|
||||
|
||||
container(
|
||||
scrollable(container(
|
||||
column![
|
||||
header,
|
||||
project_label,
|
||||
Space::with_height(16),
|
||||
inputs::section_header(&t(locale, "dashboard.entityCounts")),
|
||||
inputs::section_header(&t(locale, "dashboard.statCards")),
|
||||
counts_row,
|
||||
Space::with_height(12),
|
||||
inputs::section_header(&t(locale, "dashboard.statusCounts")),
|
||||
status_row,
|
||||
Space::with_height(20),
|
||||
inputs::section_header(&t(locale, "dashboard.timeline")),
|
||||
timeline,
|
||||
Space::with_height(20),
|
||||
row![tags, categories].spacing(16),
|
||||
Space::with_height(20),
|
||||
inputs::section_header(&t(locale, "dashboard.recentPosts")),
|
||||
recent,
|
||||
]
|
||||
.spacing(8)
|
||||
.padding(24)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn stat_card<'a>(label: &str, count: usize) -> Element<'a, Message> {
|
||||
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![
|
||||
text(count.to_string()).size(28).color(Color::WHITE),
|
||||
text(label.to_string()).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)),
|
||||
]
|
||||
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),
|
||||
)
|
||||
@@ -102,3 +190,187 @@ fn stat_card<'a>(label: &str, count: usize) -> Element<'a, Message> {
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], locale: UiLocale) -> Element<'a, Message> {
|
||||
if months.is_empty() {
|
||||
return text(t(locale, "dashboard.noTimelineData"))
|
||||
.size(13)
|
||||
.color(Color::from_rgb(0.6, 0.62, 0.68))
|
||||
.into();
|
||||
}
|
||||
|
||||
let max_count = months.iter().map(|month| month.count).max().unwrap_or(1).max(1);
|
||||
row(
|
||||
months
|
||||
.iter()
|
||||
.map(|month| {
|
||||
let height = 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], overflow_count: usize, locale: UiLocale) -> Element<'a, Message> {
|
||||
let items = tags.iter().fold(column![inputs::section_header(&t(locale, "dashboard.tagCloud"))].spacing(8), |column, tag| {
|
||||
let bg = parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28));
|
||||
column.push(
|
||||
container(
|
||||
row![
|
||||
text(tag.name.clone()).size(scale_font(tag.count)).color(contrast_color(bg)),
|
||||
text(tag.count.to_string()).size(12).color(contrast_color(bg)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.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()
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
let with_overflow = if overflow_count > 0 {
|
||||
items.push(text(format!("{} {}", t(locale, "dashboard.and"), overflow_count)).size(12).color(Color::from_rgb(0.6, 0.62, 0.68)))
|
||||
} else {
|
||||
items
|
||||
};
|
||||
|
||||
container(with_overflow).width(Length::FillPortion(1)).into()
|
||||
}
|
||||
|
||||
fn category_cloud<'a>(categories: &'a [DashboardCategory], locale: UiLocale) -> Element<'a, Message> {
|
||||
let items = categories.iter().fold(column![inputs::section_header(&t(locale, "dashboard.categoryCloud"))].spacing(8), |column, category| {
|
||||
column.push(
|
||||
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()
|
||||
}),
|
||||
)
|
||||
});
|
||||
container(items).width(Length::FillPortion(1)).into()
|
||||
}
|
||||
|
||||
fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Element<'a, Message> {
|
||||
if posts.is_empty() {
|
||||
return text(t(locale, "dashboard.noRecentPosts"))
|
||||
.size(13)
|
||||
.color(Color::from_rgb(0.6, 0.62, 0.68))
|
||||
.into();
|
||||
}
|
||||
|
||||
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 scale_font(count: usize) -> u16 {
|
||||
let clamped = count.min(20) as f32;
|
||||
(11.0 + (clamped / 20.0) * 11.0).round() as u16
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user