fix: dashboard now in as well as settings

This commit is contained in:
2026-04-09 08:11:31 +02:00
parent c6d26957dc
commit 35a934adbf
9 changed files with 1221 additions and 81 deletions

View File

@@ -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 }
}