fix: bring dashboard tag cloud and sections into bDS2 parity (closes #11)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,6 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use bds_core::db::DbQueryError as SqlError;
|
use bds_core::db::DbQueryError as SqlError;
|
||||||
use chrono::Datelike;
|
|
||||||
use iced::{Element, Subscription, Task, window};
|
use iced::{Element, Subscription, Task, window};
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -2658,10 +2657,16 @@ impl BdsApp {
|
|||||||
*monthly_counts.entry((year, month)).or_insert(0) += 1;
|
*monthly_counts.entry((year, month)).or_insert(0) += 1;
|
||||||
|
|
||||||
for category in &post.categories {
|
for category in &post.categories {
|
||||||
*category_counts.entry(category.clone()).or_insert(0) += 1;
|
let trimmed = category.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
*category_counts.entry(trimmed.to_string()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for tag in &post.tags {
|
for tag in &post.tags {
|
||||||
*tag_counts.entry(tag.to_lowercase()).or_insert(0) += 1;
|
let trimmed = tag.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
*tag_counts.entry(trimmed.to_string()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2671,48 +2676,62 @@ impl BdsApp {
|
|||||||
.count();
|
.count();
|
||||||
let total_media_size = media.iter().map(|item| item.size).sum::<i64>();
|
let total_media_size = media.iter().map(|item| item.size).sum::<i64>();
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
// Per bDS2: only the most recent 12 months that actually have posts.
|
||||||
let current_year = now.year();
|
let timeline = monthly_counts
|
||||||
let current_month = now.month() as i32;
|
.iter()
|
||||||
let timeline = (0..12)
|
|
||||||
.rev()
|
.rev()
|
||||||
.map(|offset| {
|
.take(12)
|
||||||
let total_month_index = current_year * 12 + (current_month - 1) - offset;
|
.map(|(&(year, month), &count)| DashboardTimelineMonth {
|
||||||
let year = total_month_index.div_euclid(12);
|
label: month_abbreviation(month),
|
||||||
let month = total_month_index.rem_euclid(12) + 1;
|
year,
|
||||||
let count = monthly_counts
|
count,
|
||||||
.get(&(year, month as u32))
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
DashboardTimelineMonth {
|
|
||||||
label: month_abbreviation(month as u32),
|
|
||||||
year,
|
|
||||||
count,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
.rev()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let mut tag_cloud = tags
|
let tag_colors = tags
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|tag| DashboardTag {
|
.filter_map(|tag| match tag.color {
|
||||||
count: tag_counts
|
Some(color) if !color.is_empty() => Some((tag.name, color)),
|
||||||
.get(&tag.name.to_lowercase())
|
_ => None,
|
||||||
.copied()
|
})
|
||||||
.unwrap_or(0),
|
.collect::<HashMap<_, _>>();
|
||||||
name: tag.name,
|
let distinct_tag_count = tag_counts.len();
|
||||||
color: tag.color,
|
|
||||||
|
// Per bDS2: top 40 tags by count, font scaled 11-22px relative to the
|
||||||
|
// min/max counts of the visible set, then sorted alphabetically.
|
||||||
|
let mut tag_items = tag_counts.into_iter().collect::<Vec<_>>();
|
||||||
|
tag_items.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.1
|
||||||
|
.cmp(&left.1)
|
||||||
|
.then_with(|| left.0.to_lowercase().cmp(&right.0.to_lowercase()))
|
||||||
|
});
|
||||||
|
tag_items.truncate(40);
|
||||||
|
let max_count = tag_items.iter().map(|item| item.1).max().unwrap_or(1).max(1);
|
||||||
|
let min_count = tag_items.iter().map(|item| item.1).min().unwrap_or(max_count);
|
||||||
|
let range = (max_count - min_count).max(1) as f32;
|
||||||
|
let mut tag_cloud = tag_items
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, count)| DashboardTag {
|
||||||
|
font_size: 11.0 + (count - min_count) as f32 / range * 11.0,
|
||||||
|
color: tag_colors.get(&name).cloned(),
|
||||||
|
name,
|
||||||
|
count,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
tag_cloud.sort_by_key(|left| left.name.to_lowercase());
|
tag_cloud.sort_by_key(|tag| tag.name.to_lowercase());
|
||||||
let tag_overflow_count = tag_cloud.len().saturating_sub(40);
|
|
||||||
tag_cloud.truncate(40);
|
|
||||||
|
|
||||||
let mut category_cloud = category_counts
|
let mut category_cloud = category_counts
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(name, count)| DashboardCategory { name, count })
|
.map(|(name, count)| DashboardCategory { name, count })
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
category_cloud.sort_by_key(|left| left.name.to_lowercase());
|
category_cloud.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.count
|
||||||
|
.cmp(&left.count)
|
||||||
|
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
|
||||||
|
});
|
||||||
|
|
||||||
let mut sorted_posts = posts;
|
let mut sorted_posts = posts;
|
||||||
sorted_posts.sort_by_key(|post| std::cmp::Reverse(post.updated_at));
|
sorted_posts.sort_by_key(|post| std::cmp::Reverse(post.updated_at));
|
||||||
@@ -2745,12 +2764,11 @@ impl BdsApp {
|
|||||||
media_count: media.len(),
|
media_count: media.len(),
|
||||||
image_count,
|
image_count,
|
||||||
total_media_size: format_bytes(total_media_size),
|
total_media_size: format_bytes(total_media_size),
|
||||||
tag_count: tag_counts.len(),
|
tag_count: distinct_tag_count,
|
||||||
category_count: category_cloud.len(),
|
category_count: category_cloud.len(),
|
||||||
},
|
},
|
||||||
timeline,
|
timeline,
|
||||||
tag_cloud,
|
tag_cloud,
|
||||||
tag_overflow_count,
|
|
||||||
category_cloud,
|
category_cloud,
|
||||||
recent_posts,
|
recent_posts,
|
||||||
}
|
}
|
||||||
@@ -6963,7 +6981,7 @@ mod tests {
|
|||||||
assert_eq!(dash.stats.published_count, 1);
|
assert_eq!(dash.stats.published_count, 1);
|
||||||
assert_eq!(dash.stats.media_count, 1);
|
assert_eq!(dash.stats.media_count, 1);
|
||||||
assert_eq!(dash.stats.tag_count, 2);
|
assert_eq!(dash.stats.tag_count, 2);
|
||||||
assert_eq!(dash.timeline.len(), 12);
|
assert_eq!(dash.timeline.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dash.timeline.last().map(|month| month.year),
|
dash.timeline.last().map(|month| month.year),
|
||||||
Some(now.year())
|
Some(now.year())
|
||||||
@@ -6980,6 +6998,83 @@ mod tests {
|
|||||||
assert!(!dash.category_cloud.is_empty());
|
assert!(!dash.category_cloud.is_empty());
|
||||||
assert_eq!(dash.recent_posts[0].title, "Second");
|
assert_eq!(dash.recent_posts[0].title, "Second");
|
||||||
assert_eq!(first.title, "First");
|
assert_eq!(first.title, "First");
|
||||||
|
|
||||||
|
// TagCloud guarantee: alphabetical display order, font size relative
|
||||||
|
// to the min/max counts of the visible set (equal counts -> 11px).
|
||||||
|
let names = dash
|
||||||
|
.tag_cloud
|
||||||
|
.iter()
|
||||||
|
.map(|tag| tag.name.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(names, vec!["lua", "rust"]);
|
||||||
|
assert!(dash.tag_cloud.iter().all(|tag| tag.font_size == 11.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dashboard_tag_cloud_takes_most_used_tags_and_scales_fonts() {
|
||||||
|
let (db, project, tmp) = setup();
|
||||||
|
// 42 distinct tags; "big" appears on 3 posts, "mid" on 2, the rest once.
|
||||||
|
for index in 0..3 {
|
||||||
|
post::create_post(
|
||||||
|
db.conn(),
|
||||||
|
tmp.path(),
|
||||||
|
&project.id,
|
||||||
|
&format!("Post {index}"),
|
||||||
|
Some("Body"),
|
||||||
|
vec![
|
||||||
|
"big".to_string(),
|
||||||
|
format!("solo-{index:02}"),
|
||||||
|
format!("solo-x{index:02}"),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
Some("en"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
for index in 3..20 {
|
||||||
|
post::create_post(
|
||||||
|
db.conn(),
|
||||||
|
tmp.path(),
|
||||||
|
&project.id,
|
||||||
|
&format!("Post {index}"),
|
||||||
|
Some("Body"),
|
||||||
|
vec![
|
||||||
|
format!("solo-{index:02}"),
|
||||||
|
format!("solo-x{index:02}"),
|
||||||
|
if index < 5 { "mid" } else { "big" }.to_string(),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
Some("en"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = make_app(db, project, &tmp);
|
||||||
|
let dash = app.hydrate_dashboard_state();
|
||||||
|
|
||||||
|
// 42 distinct tags overall, but only the 40 most-used are displayed.
|
||||||
|
assert_eq!(dash.stats.tag_count, 42);
|
||||||
|
assert_eq!(dash.tag_cloud.len(), 40);
|
||||||
|
let big = dash.tag_cloud.iter().find(|tag| tag.name == "big").unwrap();
|
||||||
|
let mid = dash.tag_cloud.iter().find(|tag| tag.name == "mid").unwrap();
|
||||||
|
assert_eq!(big.count, 18);
|
||||||
|
assert_eq!(mid.count, 2);
|
||||||
|
// Relative scaling over the visible set: max count -> 22px, min -> 11px.
|
||||||
|
assert_eq!(big.font_size, 22.0);
|
||||||
|
assert!(dash.tag_cloud.iter().any(|tag| tag.font_size == 11.0));
|
||||||
|
// Display order is alphabetical.
|
||||||
|
let mut sorted = dash
|
||||||
|
.tag_cloud
|
||||||
|
.iter()
|
||||||
|
.map(|tag| tag.name.to_lowercase())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let displayed = sorted.clone();
|
||||||
|
sorted.sort();
|
||||||
|
assert_eq!(displayed, sorted);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -7018,19 +7113,13 @@ mod tests {
|
|||||||
|
|
||||||
let app = make_app(db, project, &tmp);
|
let app = make_app(db, project, &tmp);
|
||||||
let dash = app.hydrate_dashboard_state();
|
let dash = app.hydrate_dashboard_state();
|
||||||
let march = dash
|
// Only months with posts appear in the timeline: the created_at month
|
||||||
.timeline
|
// (March), not the updated_at month (April).
|
||||||
.iter()
|
assert_eq!(dash.timeline.len(), 1);
|
||||||
.find(|month| month.year == 2026 && month.label == "Mar")
|
let march = &dash.timeline[0];
|
||||||
.expect("march bucket should exist");
|
assert_eq!(march.year, 2026);
|
||||||
let april = dash
|
assert_eq!(march.label, "Mar");
|
||||||
.timeline
|
|
||||||
.iter()
|
|
||||||
.find(|month| month.year == 2026 && month.label == "Apr")
|
|
||||||
.expect("april bucket should exist");
|
|
||||||
|
|
||||||
assert_eq!(march.count, 1);
|
assert_eq!(march.count, 1);
|
||||||
assert_eq!(april.count, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use bds_core::i18n::UiLocale;
|
|||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::components::inputs;
|
use crate::components::inputs;
|
||||||
use crate::i18n::t;
|
use crate::i18n::{t, tw};
|
||||||
use crate::state::tabs::{Tab, TabType};
|
use crate::state::tabs::{Tab, TabType};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -33,6 +33,8 @@ pub struct DashboardTag {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub count: usize,
|
pub count: usize,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
|
/// 11–22 px, scaled relative to the min/max counts of the displayed tags.
|
||||||
|
pub font_size: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -57,7 +59,6 @@ pub struct DashboardState {
|
|||||||
pub stats: DashboardStats,
|
pub stats: DashboardStats,
|
||||||
pub timeline: Vec<DashboardTimelineMonth>,
|
pub timeline: Vec<DashboardTimelineMonth>,
|
||||||
pub tag_cloud: Vec<DashboardTag>,
|
pub tag_cloud: Vec<DashboardTag>,
|
||||||
pub tag_overflow_count: usize,
|
|
||||||
pub category_cloud: Vec<DashboardCategory>,
|
pub category_cloud: Vec<DashboardCategory>,
|
||||||
pub recent_posts: Vec<DashboardRecentPost>,
|
pub recent_posts: Vec<DashboardRecentPost>,
|
||||||
}
|
}
|
||||||
@@ -80,7 +81,6 @@ impl DashboardState {
|
|||||||
},
|
},
|
||||||
timeline: Vec::new(),
|
timeline: Vec::new(),
|
||||||
tag_cloud: Vec::new(),
|
tag_cloud: Vec::new(),
|
||||||
tag_overflow_count: 0,
|
|
||||||
category_cloud: Vec::new(),
|
category_cloud: Vec::new(),
|
||||||
recent_posts: Vec::new(),
|
recent_posts: Vec::new(),
|
||||||
}
|
}
|
||||||
@@ -146,34 +146,37 @@ pub fn view<'a>(state: &'a DashboardState, locale: UiLocale) -> Element<'a, Mess
|
|||||||
]
|
]
|
||||||
.spacing(16);
|
.spacing(16);
|
||||||
|
|
||||||
let timeline = timeline_chart(&state.timeline, locale);
|
let mut content = column![header, project_label, Space::with_height(16), counts_row].spacing(8);
|
||||||
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);
|
|
||||||
|
|
||||||
scrollable(container(
|
if !state.timeline.is_empty() {
|
||||||
column![
|
content = content
|
||||||
header,
|
.push(Space::with_height(20))
|
||||||
project_label,
|
.push(inputs::section_header(&t(locale, "dashboard.timeline")))
|
||||||
Space::with_height(16),
|
.push(timeline_chart(&state.timeline, locale));
|
||||||
inputs::section_header(&t(locale, "dashboard.statCards")),
|
}
|
||||||
counts_row,
|
if !state.tag_cloud.is_empty() {
|
||||||
Space::with_height(20),
|
content = content
|
||||||
inputs::section_header(&t(locale, "dashboard.timeline")),
|
.push(Space::with_height(20))
|
||||||
timeline,
|
.push(inputs::section_header(&t(locale, "dashboard.tags")))
|
||||||
Space::with_height(20),
|
.push(tag_cloud(&state.tag_cloud, locale));
|
||||||
row![tags, categories].spacing(16),
|
}
|
||||||
Space::with_height(20),
|
if !state.category_cloud.is_empty() {
|
||||||
inputs::section_header(&t(locale, "dashboard.recentPosts")),
|
content = content
|
||||||
recent,
|
.push(Space::with_height(20))
|
||||||
]
|
.push(inputs::section_header(&t(locale, "dashboard.categories")))
|
||||||
.spacing(8)
|
.push(category_cloud(&state.category_cloud, locale));
|
||||||
.padding(24)
|
}
|
||||||
.width(Length::Fill),
|
if !state.recent_posts.is_empty() {
|
||||||
))
|
content = content
|
||||||
.width(Length::Fill)
|
.push(Space::with_height(20))
|
||||||
.height(Length::Fill)
|
.push(inputs::section_header(&t(locale, "dashboard.recentPosts")))
|
||||||
.into()
|
.push(recent_posts(&state.recent_posts, locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollable(container(content.padding(24).width(Length::Fill)))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a, Message> {
|
fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a, Message> {
|
||||||
@@ -258,78 +261,41 @@ fn timeline_chart<'a>(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tag_cloud<'a>(
|
fn tag_cloud<'a>(tags: &'a [DashboardTag], locale: UiLocale) -> Element<'a, Message> {
|
||||||
tags: &'a [DashboardTag],
|
let words = tags
|
||||||
overflow_count: usize,
|
.iter()
|
||||||
locale: UiLocale,
|
.map(|tag| {
|
||||||
) -> Element<'a, Message> {
|
let bg =
|
||||||
let cloud = if tags.is_empty() {
|
parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28));
|
||||||
row![
|
let fg = contrast_color(bg);
|
||||||
text(t(locale, "dashboard.noTags"))
|
tooltip(
|
||||||
.size(13)
|
container(text(tag.name.clone()).size(tag.font_size).color(fg))
|
||||||
.color(Color::from_rgb(0.6, 0.62, 0.68))
|
.padding([6, 10])
|
||||||
]
|
.style(move |_: &Theme| container::Style {
|
||||||
.spacing(8)
|
background: Some(Background::Color(bg)),
|
||||||
.wrap()
|
border: iced::Border {
|
||||||
} else {
|
radius: 999.0.into(),
|
||||||
let words = tags
|
..iced::Border::default()
|
||||||
.iter()
|
},
|
||||||
.map(|tag| {
|
..container::Style::default()
|
||||||
let bg =
|
}),
|
||||||
parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28));
|
text(post_count_label(locale, tag.count)).size(12),
|
||||||
let fg = contrast_color(bg);
|
tooltip::Position::Top,
|
||||||
tooltip(
|
)
|
||||||
container(text(tag.name.clone()).size(scale_font(tag.count)).color(fg))
|
.into()
|
||||||
.padding([6, 10])
|
})
|
||||||
.style(move |_: &Theme| container::Style {
|
.collect::<Vec<_>>();
|
||||||
background: Some(Background::Color(bg)),
|
row(words).spacing(8).wrap().into()
|
||||||
border: iced::Border {
|
|
||||||
radius: 999.0.into(),
|
|
||||||
..iced::Border::default()
|
|
||||||
},
|
|
||||||
..container::Style::default()
|
|
||||||
}),
|
|
||||||
text(format!("{} {}", tag.name, tag.count)).size(12),
|
|
||||||
tooltip::Position::Top,
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
row(words).spacing(8).wrap()
|
|
||||||
};
|
|
||||||
|
|
||||||
let content = if overflow_count > 0 {
|
|
||||||
column![
|
|
||||||
inputs::section_header(&t(locale, "dashboard.tagCloud")),
|
|
||||||
cloud,
|
|
||||||
text(format!("{} {}", t(locale, "dashboard.and"), overflow_count))
|
|
||||||
.size(12)
|
|
||||||
.color(Color::from_rgb(0.6, 0.62, 0.68)),
|
|
||||||
]
|
|
||||||
.spacing(8)
|
|
||||||
} else {
|
|
||||||
column![
|
|
||||||
inputs::section_header(&t(locale, "dashboard.tagCloud")),
|
|
||||||
cloud
|
|
||||||
]
|
|
||||||
.spacing(8)
|
|
||||||
};
|
|
||||||
|
|
||||||
container(content).width(Length::FillPortion(1)).into()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn category_cloud<'a>(
|
fn category_cloud<'a>(
|
||||||
categories: &'a [DashboardCategory],
|
categories: &'a [DashboardCategory],
|
||||||
locale: UiLocale,
|
locale: UiLocale,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let items = categories.iter().fold(
|
let badges = categories
|
||||||
column![inputs::section_header(&t(
|
.iter()
|
||||||
locale,
|
.map(|category| {
|
||||||
"dashboard.categoryCloud"
|
tooltip(
|
||||||
))]
|
|
||||||
.spacing(8),
|
|
||||||
|column, category| {
|
|
||||||
column.push(
|
|
||||||
container(
|
container(
|
||||||
row![
|
row![
|
||||||
text(category.name.clone()).size(13).color(Color::WHITE),
|
text(category.name.clone()).size(13).color(Color::WHITE),
|
||||||
@@ -348,20 +314,20 @@ fn category_cloud<'a>(
|
|||||||
},
|
},
|
||||||
..container::Style::default()
|
..container::Style::default()
|
||||||
}),
|
}),
|
||||||
|
text(post_count_label(locale, category.count)).size(12),
|
||||||
|
tooltip::Position::Top,
|
||||||
)
|
)
|
||||||
},
|
.into()
|
||||||
);
|
})
|
||||||
container(items).width(Length::FillPortion(1)).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> {
|
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(
|
column(
|
||||||
posts
|
posts
|
||||||
.iter()
|
.iter()
|
||||||
@@ -435,11 +401,6 @@ fn status_badge<'a>(status: &str) -> Element<'a, Message> {
|
|||||||
.into()
|
.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> {
|
fn parse_color(value: Option<&str>) -> Option<Color> {
|
||||||
let value = value?.trim_start_matches('#');
|
let value = value?.trim_start_matches('#');
|
||||||
if value.len() != 6 {
|
if value.len() != 6 {
|
||||||
|
|||||||
@@ -397,15 +397,9 @@ dashboard-images = Bilder
|
|||||||
dashboard-drafts = Entwürfe
|
dashboard-drafts = Entwürfe
|
||||||
dashboard-published = Veröffentlicht
|
dashboard-published = Veröffentlicht
|
||||||
dashboard-archived = Archiviert
|
dashboard-archived = Archiviert
|
||||||
dashboard-statCards = Überblick
|
dashboard-timeline = Beiträge im Zeitverlauf
|
||||||
dashboard-timeline = Zeitachse
|
dashboard-recentPosts = Kürzlich aktualisiert
|
||||||
dashboard-noTimelineData = Noch keine Zeitachsendaten
|
dashboard-postCount = { $count } Beiträge
|
||||||
dashboard-tagCloud = Tag-Wolke
|
|
||||||
dashboard-noTags = Noch keine Tags
|
|
||||||
dashboard-categoryCloud = Kategorie-Wolke
|
|
||||||
dashboard-recentPosts = Letzte Beiträge
|
|
||||||
dashboard-noRecentPosts = Keine aktuellen Beiträge
|
|
||||||
dashboard-and = und
|
|
||||||
dashboard-pin = Anheften
|
dashboard-pin = Anheften
|
||||||
common-tabNotImplemented = Dieser Bereich wird in einem späteren Meilenstein implementiert.
|
common-tabNotImplemented = Dieser Bereich wird in einem späteren Meilenstein implementiert.
|
||||||
editor-noPreviewAvailable = Keine Vorschau verfügbar
|
editor-noPreviewAvailable = Keine Vorschau verfügbar
|
||||||
|
|||||||
@@ -397,15 +397,9 @@ dashboard-images = Images
|
|||||||
dashboard-drafts = Drafts
|
dashboard-drafts = Drafts
|
||||||
dashboard-published = Published
|
dashboard-published = Published
|
||||||
dashboard-archived = Archived
|
dashboard-archived = Archived
|
||||||
dashboard-statCards = Snapshot
|
dashboard-timeline = Posts Over Time
|
||||||
dashboard-timeline = Timeline
|
dashboard-recentPosts = Recently Updated
|
||||||
dashboard-noTimelineData = No timeline data yet
|
dashboard-postCount = { $count } posts
|
||||||
dashboard-tagCloud = Tag Cloud
|
|
||||||
dashboard-noTags = No tags yet
|
|
||||||
dashboard-categoryCloud = Category Cloud
|
|
||||||
dashboard-recentPosts = Recent Posts
|
|
||||||
dashboard-noRecentPosts = No recent posts
|
|
||||||
dashboard-and = and
|
|
||||||
dashboard-pin = Pin
|
dashboard-pin = Pin
|
||||||
common-tabNotImplemented = This area will be implemented in a later milestone.
|
common-tabNotImplemented = This area will be implemented in a later milestone.
|
||||||
editor-noPreviewAvailable = No preview available
|
editor-noPreviewAvailable = No preview available
|
||||||
|
|||||||
@@ -397,15 +397,9 @@ dashboard-images = Imágenes
|
|||||||
dashboard-drafts = Borradores
|
dashboard-drafts = Borradores
|
||||||
dashboard-published = Publicados
|
dashboard-published = Publicados
|
||||||
dashboard-archived = Archivados
|
dashboard-archived = Archivados
|
||||||
dashboard-statCards = Resumen
|
dashboard-timeline = Entradas a lo largo del tiempo
|
||||||
dashboard-timeline = Cronología
|
dashboard-recentPosts = Actualizadas recientemente
|
||||||
dashboard-noTimelineData = Todavía no hay datos de cronología
|
dashboard-postCount = { $count } entradas
|
||||||
dashboard-tagCloud = Nube de etiquetas
|
|
||||||
dashboard-noTags = Todavía no hay etiquetas
|
|
||||||
dashboard-categoryCloud = Nube de categorías
|
|
||||||
dashboard-recentPosts = Publicaciones recientes
|
|
||||||
dashboard-noRecentPosts = No hay publicaciones recientes
|
|
||||||
dashboard-and = y
|
|
||||||
dashboard-pin = Fijar
|
dashboard-pin = Fijar
|
||||||
common-tabNotImplemented = Esta sección se implementará en un hito posterior.
|
common-tabNotImplemented = Esta sección se implementará en un hito posterior.
|
||||||
editor-noPreviewAvailable = No hay vista previa disponible
|
editor-noPreviewAvailable = No hay vista previa disponible
|
||||||
|
|||||||
@@ -397,15 +397,9 @@ dashboard-images = Images
|
|||||||
dashboard-drafts = Brouillons
|
dashboard-drafts = Brouillons
|
||||||
dashboard-published = Publiés
|
dashboard-published = Publiés
|
||||||
dashboard-archived = Archivés
|
dashboard-archived = Archivés
|
||||||
dashboard-statCards = Instantané
|
dashboard-timeline = Articles dans le temps
|
||||||
dashboard-timeline = Chronologie
|
dashboard-recentPosts = Récemment mis à jour
|
||||||
dashboard-noTimelineData = Aucune donnée de chronologie
|
dashboard-postCount = { $count } articles
|
||||||
dashboard-tagCloud = Nuage de tags
|
|
||||||
dashboard-noTags = Aucun tag pour le moment
|
|
||||||
dashboard-categoryCloud = Nuage de catégories
|
|
||||||
dashboard-recentPosts = Articles récents
|
|
||||||
dashboard-noRecentPosts = Aucun article récent
|
|
||||||
dashboard-and = et
|
|
||||||
dashboard-pin = Épingler
|
dashboard-pin = Épingler
|
||||||
common-tabNotImplemented = Cette section sera mise en œuvre lors d’une étape ultérieure.
|
common-tabNotImplemented = Cette section sera mise en œuvre lors d’une étape ultérieure.
|
||||||
editor-noPreviewAvailable = Aucun aperçu disponible
|
editor-noPreviewAvailable = Aucun aperçu disponible
|
||||||
|
|||||||
@@ -397,15 +397,9 @@ dashboard-images = Immagini
|
|||||||
dashboard-drafts = Bozze
|
dashboard-drafts = Bozze
|
||||||
dashboard-published = Pubblicati
|
dashboard-published = Pubblicati
|
||||||
dashboard-archived = Archiviati
|
dashboard-archived = Archiviati
|
||||||
dashboard-statCards = Riepilogo
|
dashboard-timeline = Post nel tempo
|
||||||
dashboard-timeline = Cronologia
|
dashboard-recentPosts = Aggiornati di recente
|
||||||
dashboard-noTimelineData = Nessun dato cronologico disponibile
|
dashboard-postCount = { $count } post
|
||||||
dashboard-tagCloud = Nuvola di tag
|
|
||||||
dashboard-noTags = Nessun tag per ora
|
|
||||||
dashboard-categoryCloud = Nuvola di categorie
|
|
||||||
dashboard-recentPosts = Post recenti
|
|
||||||
dashboard-noRecentPosts = Nessun post recente
|
|
||||||
dashboard-and = e
|
|
||||||
dashboard-pin = Fissa
|
dashboard-pin = Fissa
|
||||||
common-tabNotImplemented = Questa sezione verrà implementata in una fase successiva.
|
common-tabNotImplemented = Questa sezione verrà implementata in una fase successiva.
|
||||||
editor-noPreviewAvailable = Nessuna anteprima disponibile
|
editor-noPreviewAvailable = Nessuna anteprima disponibile
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ value DashboardTimelineMonth {
|
|||||||
|
|
||||||
value DashboardTagCloud {
|
value DashboardTagCloud {
|
||||||
tags: List<DashboardTag>
|
tags: List<DashboardTag>
|
||||||
overflow_count: Integer? -- "and N more" when > config.dashboard_max_tags
|
|
||||||
}
|
}
|
||||||
|
|
||||||
value DashboardTag {
|
value DashboardTag {
|
||||||
@@ -106,7 +105,6 @@ surface DashboardSurface {
|
|||||||
t.name
|
t.name
|
||||||
t.count
|
t.count
|
||||||
t.color
|
t.color
|
||||||
dash.tag_cloud.overflow_count when dash.tag_cloud.overflow_count != null
|
|
||||||
for c in dash.category_cloud.categories:
|
for c in dash.category_cloud.categories:
|
||||||
c.name
|
c.name
|
||||||
c.count
|
c.count
|
||||||
@@ -131,16 +129,18 @@ surface DashboardSurface {
|
|||||||
-- Bar height proportional to max count.
|
-- Bar height proportional to max count.
|
||||||
|
|
||||||
@guarantee TagCloud
|
@guarantee TagCloud
|
||||||
-- Up to config.dashboard_max_tags tags, sorted alphabetically.
|
-- Tags counted from post tags (trimmed, non-empty entries).
|
||||||
|
-- The config.dashboard_max_tags most-used tags are shown, sorted alphabetically.
|
||||||
-- Font size scaled config.dashboard_tag_min_font to config.dashboard_tag_max_font px
|
-- Font size scaled config.dashboard_tag_min_font to config.dashboard_tag_max_font px
|
||||||
-- based on post count.
|
-- relative to the min/max post counts of the displayed tags.
|
||||||
-- Tags with colours get coloured background with contrast text.
|
-- Tags with colours get coloured background with contrast text.
|
||||||
-- Hover title shows post count.
|
-- Hover title shows post count.
|
||||||
-- "and N more" text when overflow_count > 0.
|
-- Section hidden when there are no tags.
|
||||||
|
|
||||||
@guarantee CategoryCloud
|
@guarantee CategoryCloud
|
||||||
-- All categories as badge-like tags.
|
-- All categories as badge-like tags, sorted by post count descending
|
||||||
-- Each shows category name + count.
|
-- then name ascending.
|
||||||
|
-- Each shows category name + count. Section hidden when empty.
|
||||||
|
|
||||||
@guarantee RecentPosts
|
@guarantee RecentPosts
|
||||||
-- Last config.dashboard_recent_count posts by updatedAt descending.
|
-- Last config.dashboard_recent_count posts by updatedAt descending.
|
||||||
|
|||||||
Reference in New Issue
Block a user