feat: sidebar filtering

This commit is contained in:
2026-04-05 14:45:20 +02:00
parent 0cf59da467
commit 83ad6a2bf7
12 changed files with 1291 additions and 58 deletions

View File

@@ -1,4 +1,4 @@
use iced::widget::{button, column, container, scrollable, text, Space};
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::widget::text::Shaping;
use iced::{Background, Border, Color, Element, Length, Theme};
@@ -8,6 +8,7 @@ use bds_core::model::{Media, Post, Script, Template};
use crate::app::Message;
use crate::i18n::t;
use crate::state::navigation::SidebarView;
use crate::state::sidebar_filter::{CalendarYear, MediaFilter, PostFilter};
use crate::state::tabs::{Tab, TabType};
/// Sidebar container style — dark background.
@@ -118,12 +119,366 @@ fn format_post_date(unix_ms: i64) -> String {
dt.format("%b %d, %Y").to_string()
}
/// Search input style.
fn search_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style {
text_input::Style {
background: Background::Color(Color::from_rgb(0.12, 0.12, 0.15)),
border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0,
radius: 3.0.into(),
},
icon: Color::from_rgb(0.50, 0.50, 0.55),
placeholder: Color::from_rgb(0.45, 0.45, 0.50),
value: Color::from_rgb(0.85, 0.85, 0.90),
selection: Color::from_rgba(0.35, 0.55, 0.85, 0.4),
}
}
/// Style for filter toggle / clear buttons in the header.
fn filter_button_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match status {
button::Status::Hovered => Color::from_rgb(0.25, 0.25, 0.30),
_ => Color::TRANSPARENT,
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::from_rgb(0.60, 0.60, 0.65),
border: Border::default(),
..button::Style::default()
}
}
/// Active filter toggle button.
fn filter_button_active_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match status {
button::Status::Hovered => Color::from_rgb(0.25, 0.30, 0.40),
_ => Color::from_rgb(0.20, 0.25, 0.35),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::from_rgb(0.55, 0.70, 0.95),
border: Border::default(),
..button::Style::default()
}
}
/// Tag/category chip style (unselected).
fn chip_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match status {
button::Status::Hovered => Color::from_rgb(0.24, 0.24, 0.30),
_ => Color::from_rgb(0.18, 0.18, 0.22),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::from_rgb(0.70, 0.70, 0.75),
border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0,
radius: 10.0.into(),
},
..button::Style::default()
}
}
/// Tag/category chip style (selected).
fn chip_selected_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match status {
button::Status::Hovered => Color::from_rgb(0.25, 0.35, 0.55),
_ => Color::from_rgb(0.20, 0.30, 0.50),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::from_rgb(0.85, 0.90, 1.0),
border: Border {
color: Color::from_rgb(0.40, 0.55, 0.80),
width: 1.0,
radius: 10.0.into(),
},
..button::Style::default()
}
}
/// Calendar year/month button style.
fn calendar_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match status {
button::Status::Hovered => Color::from_rgb(0.22, 0.22, 0.27),
_ => Color::TRANSPARENT,
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::from_rgb(0.70, 0.70, 0.75),
border: Border::default(),
..button::Style::default()
}
}
/// Calendar year/month button style (selected).
fn calendar_selected_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match status {
button::Status::Hovered => Color::from_rgb(0.25, 0.30, 0.40),
_ => Color::from_rgb(0.20, 0.25, 0.35),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::from_rgb(0.55, 0.70, 0.95),
border: Border::default(),
..button::Style::default()
}
}
/// "Clear All Filters" button style.
fn clear_filters_style(_theme: &Theme, status: button::Status) -> button::Style {
let color = match status {
button::Status::Hovered => Color::from_rgb(0.90, 0.50, 0.50),
_ => Color::from_rgb(0.75, 0.45, 0.45),
};
button::Style {
background: Some(Background::Color(Color::TRANSPARENT)),
text_color: color,
border: Border::default(),
..button::Style::default()
}
}
/// Month name abbreviation for calendar display.
fn month_abbr(month: u32) -> &'static str {
match month {
1 => "Jan", 2 => "Feb", 3 => "Mar", 4 => "Apr",
5 => "May", 6 => "Jun", 7 => "Jul", 8 => "Aug",
9 => "Sep", 10 => "Oct", 11 => "Nov", 12 => "Dec",
_ => "???",
}
}
/// Build the calendar archive tree widget.
fn calendar_widget(
years: &[CalendarYear],
selected_year: Option<i32>,
selected_month: Option<u32>,
on_year: impl Fn(Option<i32>) -> Message + 'static + Clone,
on_month: impl Fn(Option<u32>) -> Message + 'static + Clone,
locale: UiLocale,
) -> Element<'static, Message> {
let muted = Color::from_rgb(0.50, 0.50, 0.55);
let mut items: Vec<Element<'static, Message>> = Vec::new();
let section_label = text(t(locale, "sidebar.filter.calendar"))
.size(10)
.shaping(Shaping::Advanced)
.color(muted);
items.push(section_label.into());
for cy in years {
let year_selected = selected_year == Some(cy.year);
let total: usize = cy.months.iter().map(|m| m.count).sum();
let label = format!("{} ({})", cy.year, total);
let year_val = cy.year;
let on_year_clone = on_year.clone();
let style_fn = if year_selected { calendar_selected_style } else { calendar_style };
let year_btn = button(
text(label).size(11).shaping(Shaping::Advanced)
)
.on_press(if year_selected {
on_year_clone(None)
} else {
on_year_clone(Some(year_val))
})
.padding([2, 4])
.style(style_fn);
items.push(year_btn.into());
// Show months only when this year is selected
if year_selected {
for cm in &cy.months {
let month_selected = selected_month == Some(cm.month);
let label = format!(" {} ({})", month_abbr(cm.month), cm.count);
let month_val = cm.month;
let on_month_clone = on_month.clone();
let style_fn = if month_selected { calendar_selected_style } else { calendar_style };
let month_btn = button(
text(label).size(10).shaping(Shaping::Advanced)
)
.on_press(if month_selected {
on_month_clone(None)
} else {
on_month_clone(Some(month_val))
})
.padding([1, 4])
.style(style_fn);
items.push(month_btn.into());
}
}
}
iced::widget::Column::with_children(items).spacing(1).into()
}
/// Build chip selector for tags or categories.
fn chip_selector(
label: &str,
available: &[String],
selected: &[String],
on_toggle: impl Fn(String) -> Message + 'static + Clone,
locale: UiLocale,
) -> Element<'static, Message> {
let muted = Color::from_rgb(0.50, 0.50, 0.55);
let mut items: Vec<Element<'static, Message>> = Vec::new();
let section_label = text(t(locale, label))
.size(10)
.shaping(Shaping::Advanced)
.color(muted);
items.push(section_label.into());
// Wrap chips in rows
let mut chip_row: Vec<Element<'static, Message>> = Vec::new();
for tag in available {
let is_selected = selected.contains(tag);
let tag_clone = tag.clone();
let on_toggle_clone = on_toggle.clone();
let style_fn = if is_selected { chip_selected_style } else { chip_style };
let chip = button(
text(tag.clone()).size(10).shaping(Shaping::Advanced)
)
.on_press(on_toggle_clone(tag_clone))
.padding([2, 6])
.style(style_fn);
chip_row.push(chip.into());
}
if !chip_row.is_empty() {
let mut collected: Vec<Element<'static, Message>> = chip_row.into_iter().collect();
while !collected.is_empty() {
let chunk_size = 3.min(collected.len());
let chunk: Vec<Element<'static, Message>> = collected.drain(..chunk_size).collect();
items.push(
iced::widget::Row::with_children(chunk).spacing(4).into()
);
}
}
iced::widget::Column::with_children(items).spacing(2).into()
}
/// Build the filter panel for Posts/Pages view.
fn post_filter_panel(
filter: &PostFilter,
is_pages: bool,
locale: UiLocale,
) -> Element<'static, Message> {
let mut sections: Vec<Element<'static, Message>> = Vec::new();
// Calendar archive
if !filter.calendar_years.is_empty() {
sections.push(calendar_widget(
&filter.calendar_years,
filter.calendar.selected_year,
filter.calendar.selected_month,
|y| Message::SetPostCalendarYear(y),
|m| Message::SetPostCalendarMonth(m),
locale,
));
sections.push(Space::with_height(4.0).into());
}
// Tag chips
if !filter.available_tags.is_empty() {
sections.push(chip_selector(
"sidebar.filter.tags",
&filter.available_tags,
&filter.tag_filter,
|tag| Message::TogglePostTagFilter(tag),
locale,
));
sections.push(Space::with_height(4.0).into());
}
// Category chips (not shown for Pages since Pages IS a category filter)
if !is_pages && !filter.available_categories.is_empty() {
sections.push(chip_selector(
"sidebar.filter.categories",
&filter.available_categories,
&filter.category_filter,
|cat| Message::TogglePostCategoryFilter(cat),
locale,
));
sections.push(Space::with_height(4.0).into());
}
// Clear all filters button
if filter.has_active_filters() {
sections.push(
button(
text(t(locale, "sidebar.filter.clearAll"))
.size(10)
.shaping(Shaping::Advanced)
)
.on_press(Message::ClearPostFilters)
.padding([2, 4])
.style(clear_filters_style)
.into()
);
}
iced::widget::Column::with_children(sections).spacing(2).into()
}
/// Build the filter panel for Media view.
fn media_filter_panel(
filter: &MediaFilter,
locale: UiLocale,
) -> Element<'static, Message> {
let mut sections: Vec<Element<'static, Message>> = Vec::new();
if !filter.calendar_years.is_empty() {
sections.push(calendar_widget(
&filter.calendar_years,
filter.calendar.selected_year,
filter.calendar.selected_month,
|y| Message::SetMediaCalendarYear(y),
|m| Message::SetMediaCalendarMonth(m),
locale,
));
sections.push(Space::with_height(4.0).into());
}
if !filter.available_tags.is_empty() {
sections.push(chip_selector(
"sidebar.filter.tags",
&filter.available_tags,
&filter.tag_filter,
|tag| Message::ToggleMediaTagFilter(tag),
locale,
));
sections.push(Space::with_height(4.0).into());
}
if filter.has_active_filters() {
sections.push(
button(
text(t(locale, "sidebar.filter.clearAll"))
.size(10)
.shaping(Shaping::Advanced)
)
.on_press(Message::ClearMediaFilters)
.padding([2, 4])
.style(clear_filters_style)
.into()
);
}
iced::widget::Column::with_children(sections).spacing(2).into()
}
pub fn view(
sidebar_view: SidebarView,
posts: &[Post],
media: &[Media],
scripts: &[Script],
templates: &[Template],
post_filter: &PostFilter,
media_filter: &MediaFilter,
width: f32,
active_tab: Option<&str>,
locale: UiLocale,
@@ -137,13 +492,66 @@ pub fn view(
.color(Color::from_rgb(0.85, 0.85, 0.90));
let body: Element<'static, Message> = match sidebar_view {
SidebarView::Posts => {
SidebarView::Posts | SidebarView::Pages => {
let is_pages = sidebar_view == SidebarView::Pages;
let filter = post_filter;
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
// Search input + filter toggle row
let search = text_input(
&t(locale, "sidebar.filter.search"),
&filter.search_query,
)
.on_input(Message::PostSearchChanged)
.size(11)
.padding([4, 6])
.width(Length::Fill)
.style(search_input_style);
let has_filters = filter.has_active_filters();
let toggle_style = if filter.filter_panel_visible || has_filters {
filter_button_active_style
} else {
filter_button_style
};
let toggle_label = if has_filters {
"\u{2B50}" // ⭐ indicates active filters
} else {
"\u{25BC}" // ▼ toggle icon
};
let filter_toggle = button(
text(toggle_label).size(11).shaping(Shaping::Advanced)
)
.on_press(Message::TogglePostFilterPanel)
.padding([4, 6])
.style(toggle_style);
top_items.push(
row![search, filter_toggle].spacing(4).into()
);
// Filter panel (collapsible)
if filter.filter_panel_visible {
top_items.push(Space::with_height(4.0).into());
top_items.push(post_filter_panel(filter, is_pages, locale));
top_items.push(Space::with_height(4.0).into());
}
// Post list
if posts.is_empty() {
text(t(locale, placeholder_key(sidebar_view)))
.size(12)
.shaping(Shaping::Advanced)
.color(muted)
.into()
let key = if has_filters {
"sidebar.filter.noResults"
} else {
placeholder_key(sidebar_view)
};
top_items.push(
text(t(locale, key))
.size(12)
.shaping(Shaping::Advanced)
.color(muted)
.into()
);
} else {
let section_header = |label: &str| -> Element<'static, Message> {
text(label.to_string())
@@ -155,18 +563,14 @@ pub fn view(
let make_post_item = |p: &Post| -> Element<'static, Message> {
let is_active = active_tab == Some(p.id.as_str());
// Per sidebar_views.allium PostTypeIcon
let icon = post_type_icon(&p.categories);
let status_indicator = match p.status {
bds_core::model::PostStatus::Draft => "\u{25CB}",
bds_core::model::PostStatus::Published => "\u{25CF}",
bds_core::model::PostStatus::Archived => "\u{25A1}",
};
// Per sidebar_views.allium PostDateFormat
let date = format_post_date(p.created_at);
// Truncate title to fit sidebar width, accounting for
// padding and the status/icon prefix.
let prefix_chars: usize = 5; // icon + space + status + space
let prefix_chars: usize = 5;
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX
- (prefix_chars as f32 * AVG_CHAR_WIDTH_PX);
let display_title = truncate_to_fit(&p.title, text_px);
@@ -198,62 +602,106 @@ pub fn view(
.into()
};
let mut sections: Vec<Element<'static, Message>> = Vec::new();
// Draft section
let drafts: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Draft).collect();
if !drafts.is_empty() {
sections.push(section_header(&t(locale, "sidebar.drafts")));
top_items.push(section_header(&t(locale, "sidebar.drafts")));
for p in &drafts {
sections.push(make_post_item(p));
top_items.push(make_post_item(p));
}
sections.push(Space::with_height(6.0).into());
top_items.push(Space::with_height(6.0).into());
}
// Published section
let published: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Published).collect();
if !published.is_empty() {
sections.push(section_header(&t(locale, "sidebar.published")));
top_items.push(section_header(&t(locale, "sidebar.published")));
for p in &published {
sections.push(make_post_item(p));
top_items.push(make_post_item(p));
}
sections.push(Space::with_height(6.0).into());
top_items.push(Space::with_height(6.0).into());
}
// Archived section
let archived: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Archived).collect();
if !archived.is_empty() {
sections.push(section_header(&t(locale, "sidebar.archived")));
top_items.push(section_header(&t(locale, "sidebar.archived")));
for p in &archived {
sections.push(make_post_item(p));
top_items.push(make_post_item(p));
}
}
iced::widget::Column::with_children(sections)
.spacing(1)
.into()
}
iced::widget::Column::with_children(top_items)
.spacing(1)
.into()
}
SidebarView::Media => {
let filter = media_filter;
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
// Search input + filter toggle row
let search = text_input(
&t(locale, "sidebar.filter.search"),
&filter.search_query,
)
.on_input(Message::MediaSearchChanged)
.size(11)
.padding([4, 6])
.width(Length::Fill)
.style(search_input_style);
let has_filters = filter.has_active_filters();
let toggle_style = if filter.filter_panel_visible || has_filters {
filter_button_active_style
} else {
filter_button_style
};
let toggle_label = if has_filters {
"\u{2B50}" // ⭐ indicates active filters
} else {
"\u{25BC}" // ▼ toggle icon
};
let filter_toggle = button(
text(toggle_label).size(11).shaping(Shaping::Advanced)
)
.on_press(Message::ToggleMediaFilterPanel)
.padding([4, 6])
.style(toggle_style);
top_items.push(
row![search, filter_toggle].spacing(4).into()
);
// Filter panel (collapsible)
if filter.filter_panel_visible {
top_items.push(Space::with_height(4.0).into());
top_items.push(media_filter_panel(filter, locale));
top_items.push(Space::with_height(4.0).into());
}
if media.is_empty() {
text(t(locale, placeholder_key(sidebar_view)))
.size(12)
.shaping(Shaping::Advanced)
.color(muted)
.into()
let key = if has_filters {
"sidebar.filter.noResults"
} else {
placeholder_key(sidebar_view)
};
top_items.push(
text(t(locale, key))
.size(12)
.shaping(Shaping::Advanced)
.color(muted)
.into()
);
} else {
let items: Vec<Element<'static, Message>> = media
.iter()
.map(|m| {
let is_active = active_tab == Some(m.id.as_str());
// Per sidebar_views.allium MediaGridItem: title truncated to 60 chars + "..."
// if over limit; fallback originalName (no truncation).
// Additionally truncate to fit sidebar width.
let display_name = match m.title.as_deref() {
Some(title) => truncate_media_title(title),
None => m.original_name.clone(),
};
// Emoji prefix "🖼 " is ~2 chars wide
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 16.0;
let display_name = truncate_to_fit(&display_name, text_px);
let label = format!("\u{1F5BC} {display_name}");
@@ -280,10 +728,12 @@ pub fn view(
.into()
})
.collect();
iced::widget::Column::with_children(items)
.spacing(1)
.into()
top_items.extend(items);
}
iced::widget::Column::with_children(top_items)
.spacing(1)
.into()
}
SidebarView::Scripts => {
if scripts.is_empty() {