feat: sidebar filtering
This commit is contained in:
@@ -116,6 +116,168 @@ pub fn delete_media(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Filtered queries (per sidebar_views.allium MediaView) ───
|
||||
|
||||
/// Parameters for filtered media listing.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MediaFilterParams {
|
||||
/// FTS search query (empty = no search filter).
|
||||
pub search_query: String,
|
||||
/// Year filter from calendar archive.
|
||||
pub year: Option<i32>,
|
||||
/// Month filter (1-12) from calendar archive.
|
||||
pub month: Option<u32>,
|
||||
/// Tag filter (media must have ALL of these tags).
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
impl MediaFilterParams {
|
||||
pub fn has_active_filters(&self) -> bool {
|
||||
!self.search_query.is_empty()
|
||||
|| self.year.is_some()
|
||||
|| !self.tags.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// List media with optional filters applied.
|
||||
pub fn list_media_filtered(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
filters: &MediaFilterParams,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<Media>> {
|
||||
let mut conditions = vec!["project_id = ?1".to_string()];
|
||||
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
param_values.push(Box::new(project_id.to_string()));
|
||||
|
||||
if !filters.search_query.is_empty() {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%{}%", filters.search_query.replace('%', "\\%"));
|
||||
conditions.push(format!(
|
||||
"(COALESCE(title, original_name) LIKE ?{idx} ESCAPE '\\')"
|
||||
));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
if let Some(year) = filters.year {
|
||||
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
|
||||
if let Some(month) = filters.month {
|
||||
let m_start = chrono::NaiveDate::from_ymd_opt(year, month, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
let next_month = if month == 12 {
|
||||
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||
} else {
|
||||
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1)
|
||||
}
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
|
||||
let idx1 = param_values.len() + 1;
|
||||
let idx2 = param_values.len() + 2;
|
||||
conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
|
||||
param_values.push(Box::new(m_start));
|
||||
param_values.push(Box::new(next_month));
|
||||
} else {
|
||||
let idx1 = param_values.len() + 1;
|
||||
let idx2 = param_values.len() + 2;
|
||||
conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
|
||||
param_values.push(Box::new(start));
|
||||
param_values.push(Box::new(end));
|
||||
}
|
||||
}
|
||||
|
||||
for tag in &filters.tags {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%\"{}\"%", tag.replace('"', "\\\""));
|
||||
conditions.push(format!("(tags LIKE ?{idx})"));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
let where_clause = conditions.join(" AND ");
|
||||
let idx_limit = param_values.len() + 1;
|
||||
let idx_offset = param_values.len() + 2;
|
||||
param_values.push(Box::new(limit));
|
||||
param_values.push(Box::new(offset));
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {MEDIA_COLUMNS} FROM media WHERE {where_clause} ORDER BY created_at DESC LIMIT ?{idx_limit} OFFSET ?{idx_offset}"
|
||||
);
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
param_values.iter().map(|b| b.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_refs.as_slice(), media_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
/// Year/month counts for the media calendar archive widget.
|
||||
pub fn media_calendar_counts(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<(i32, u32, usize)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
CAST(strftime('%Y', created_at / 1000, 'unixepoch') AS INTEGER) AS y,
|
||||
CAST(strftime('%m', created_at / 1000, 'unixepoch') AS INTEGER) AS m,
|
||||
COUNT(*) AS cnt
|
||||
FROM media
|
||||
WHERE project_id = ?1
|
||||
GROUP BY y, m
|
||||
ORDER BY y DESC, m DESC"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, i32>(0)?,
|
||||
row.get::<_, u32>(1)?,
|
||||
row.get::<_, usize>(2)?,
|
||||
))
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
/// Collect all distinct tag values across media for a project.
|
||||
pub fn distinct_media_tags(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT DISTINCT tags FROM media WHERE project_id = ?1 AND tags != '[]'"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
let mut all_tags = std::collections::BTreeSet::new();
|
||||
for json_str in rows {
|
||||
if let Ok(json_str) = json_str {
|
||||
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||
all_tags.extend(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all_tags.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Test helper: create a minimal Media value (available to sibling test modules).
|
||||
#[cfg(test)]
|
||||
pub fn make_test_media(id: &str, project_id: &str) -> Media {
|
||||
|
||||
@@ -199,6 +199,243 @@ pub fn count_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::
|
||||
)
|
||||
}
|
||||
|
||||
// ── Filtered queries (per sidebar_views.allium PostsView) ───
|
||||
|
||||
/// Parameters for filtered post listing.
|
||||
/// Drafts always shown regardless of filters per spec.
|
||||
/// Published/archived respect all active filters.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PostFilterParams {
|
||||
/// FTS search query (empty = no search filter).
|
||||
pub search_query: String,
|
||||
/// Year filter from calendar archive.
|
||||
pub year: Option<i32>,
|
||||
/// Month filter (1-12) from calendar archive.
|
||||
pub month: Option<u32>,
|
||||
/// Tag filter (post must have ALL of these tags).
|
||||
pub tags: Vec<String>,
|
||||
/// Category filter (post must have at least one of these categories).
|
||||
pub categories: Vec<String>,
|
||||
/// If true, excludes posts that have category "page" (case-insensitive).
|
||||
/// Used by PostsView to hide pages from the posts list.
|
||||
pub exclude_pages: bool,
|
||||
/// If true, only includes posts that have category "page" (case-insensitive).
|
||||
/// Used by PagesView.
|
||||
pub pages_only: bool,
|
||||
}
|
||||
|
||||
impl PostFilterParams {
|
||||
pub fn has_active_filters(&self) -> bool {
|
||||
!self.search_query.is_empty()
|
||||
|| self.year.is_some()
|
||||
|| !self.tags.is_empty()
|
||||
|| !self.categories.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// List posts with optional filters applied.
|
||||
/// Per sidebar_views.allium: drafts always show regardless of filters.
|
||||
/// Published/archived sections respect active filters.
|
||||
///
|
||||
/// Returns all matching posts (up to `limit`), ordered by created_at DESC.
|
||||
/// Caller splits into draft/published/archived sections.
|
||||
pub fn list_posts_filtered(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
filters: &PostFilterParams,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<Post>> {
|
||||
// Build dynamic WHERE clause
|
||||
let mut conditions = vec!["p.project_id = ?1".to_string()];
|
||||
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
param_values.push(Box::new(project_id.to_string()));
|
||||
|
||||
// pages_only / exclude_pages
|
||||
if filters.pages_only {
|
||||
conditions.push("LOWER(p.categories) LIKE '%\"page\"%'".to_string());
|
||||
} else if filters.exclude_pages {
|
||||
conditions.push("LOWER(p.categories) NOT LIKE '%\"page\"%'".to_string());
|
||||
}
|
||||
|
||||
// For non-draft posts, apply filters. Drafts always pass.
|
||||
// We build this as: (status = 'draft') OR (filter conditions)
|
||||
let mut filter_conditions: Vec<String> = Vec::new();
|
||||
|
||||
if !filters.search_query.is_empty() {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%{}%", filters.search_query.replace('%', "\\%"));
|
||||
filter_conditions.push(format!("(p.title LIKE ?{idx} ESCAPE '\\')"));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
if let Some(year) = filters.year {
|
||||
// created_at is unix ms; compute year range
|
||||
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
|
||||
if let Some(month) = filters.month {
|
||||
let m_start = chrono::NaiveDate::from_ymd_opt(year, month, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
let next_month = if month == 12 {
|
||||
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||
} else {
|
||||
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1)
|
||||
}
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
|
||||
let idx1 = param_values.len() + 1;
|
||||
let idx2 = param_values.len() + 2;
|
||||
filter_conditions.push(format!("(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"));
|
||||
param_values.push(Box::new(m_start));
|
||||
param_values.push(Box::new(next_month));
|
||||
} else {
|
||||
let idx1 = param_values.len() + 1;
|
||||
let idx2 = param_values.len() + 2;
|
||||
filter_conditions.push(format!("(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"));
|
||||
param_values.push(Box::new(start));
|
||||
param_values.push(Box::new(end));
|
||||
}
|
||||
}
|
||||
|
||||
for tag in &filters.tags {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%\"{}\"%", tag.replace('"', "\\\""));
|
||||
filter_conditions.push(format!("(p.tags LIKE ?{idx})"));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
for cat in &filters.categories {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%\"{}\"%", cat.replace('"', "\\\""));
|
||||
filter_conditions.push(format!("(p.categories LIKE ?{idx})"));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
// If there are active filter conditions, apply them only to non-draft posts
|
||||
if !filter_conditions.is_empty() {
|
||||
let combined = filter_conditions.join(" AND ");
|
||||
conditions.push(format!("(p.status = 'draft' OR ({combined}))"));
|
||||
}
|
||||
|
||||
let where_clause = conditions.join(" AND ");
|
||||
let idx_limit = param_values.len() + 1;
|
||||
let idx_offset = param_values.len() + 2;
|
||||
param_values.push(Box::new(limit));
|
||||
param_values.push(Box::new(offset));
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {POST_COLUMNS} FROM posts p WHERE {where_clause} ORDER BY p.created_at DESC LIMIT ?{idx_limit} OFFSET ?{idx_offset}"
|
||||
);
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
param_values.iter().map(|b| b.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_refs.as_slice(), post_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
/// Year/month counts for the calendar archive widget.
|
||||
/// Returns (year, month, count) tuples, ordered by year DESC, month DESC.
|
||||
pub fn post_calendar_counts(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
pages_only: bool,
|
||||
exclude_pages: bool,
|
||||
) -> rusqlite::Result<Vec<(i32, u32, usize)>> {
|
||||
let page_filter = if pages_only {
|
||||
" AND LOWER(categories) LIKE '%\"page\"%'"
|
||||
} else if exclude_pages {
|
||||
" AND LOWER(categories) NOT LIKE '%\"page\"%'"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT
|
||||
CAST(strftime('%Y', created_at / 1000, 'unixepoch') AS INTEGER) AS y,
|
||||
CAST(strftime('%m', created_at / 1000, 'unixepoch') AS INTEGER) AS m,
|
||||
COUNT(*) AS cnt
|
||||
FROM posts
|
||||
WHERE project_id = ?1{page_filter}
|
||||
GROUP BY y, m
|
||||
ORDER BY y DESC, m DESC"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, i32>(0)?,
|
||||
row.get::<_, u32>(1)?,
|
||||
row.get::<_, usize>(2)?,
|
||||
))
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
/// Collect all distinct tag values across posts for a project.
|
||||
pub fn distinct_post_tags(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT DISTINCT tags FROM posts WHERE project_id = ?1 AND tags != '[]'"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
let mut all_tags = std::collections::BTreeSet::new();
|
||||
for json_str in rows {
|
||||
if let Ok(json_str) = json_str {
|
||||
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||
all_tags.extend(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all_tags.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Collect all distinct category values across posts for a project.
|
||||
pub fn distinct_post_categories(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT DISTINCT categories FROM posts WHERE project_id = ?1 AND categories != '[]'"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
let mut all_cats = std::collections::BTreeSet::new();
|
||||
for json_str in rows {
|
||||
if let Ok(json_str) = json_str {
|
||||
if let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||
all_cats.extend(cats);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all_cats.into_iter().collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -14,6 +14,9 @@ use crate::platform::menu::{self, MenuAction, MenuRegistry};
|
||||
use crate::state::navigation::{
|
||||
handle_activity_click, OutputEntry, PanelTab, SidebarView, TaskSnapshot,
|
||||
};
|
||||
use crate::state::sidebar_filter::{
|
||||
CalendarMonth, CalendarYear, MediaFilter, PostFilter,
|
||||
};
|
||||
use crate::state::tabs::{self, Tab, TabType};
|
||||
use crate::state::toast::{Toast, ToastLevel};
|
||||
use crate::views::{modal, workspace};
|
||||
@@ -79,6 +82,21 @@ pub enum Message {
|
||||
DismissToast(u64),
|
||||
ExpireToasts,
|
||||
|
||||
// Sidebar filters
|
||||
PostSearchChanged(String),
|
||||
TogglePostFilterPanel,
|
||||
SetPostCalendarYear(Option<i32>),
|
||||
SetPostCalendarMonth(Option<u32>),
|
||||
TogglePostTagFilter(String),
|
||||
TogglePostCategoryFilter(String),
|
||||
ClearPostFilters,
|
||||
MediaSearchChanged(String),
|
||||
ToggleMediaFilterPanel,
|
||||
SetMediaCalendarYear(Option<i32>),
|
||||
SetMediaCalendarMonth(Option<u32>),
|
||||
ToggleMediaTagFilter(String),
|
||||
ClearMediaFilters,
|
||||
|
||||
// Modal
|
||||
ShowModal(modal::ModalState),
|
||||
DismissModal,
|
||||
@@ -122,6 +140,11 @@ pub struct BdsApp {
|
||||
sidebar_scripts: Vec<Script>,
|
||||
sidebar_templates: Vec<Template>,
|
||||
|
||||
// Sidebar filters (per sidebar_views.allium PostsView / MediaView)
|
||||
post_filter: PostFilter,
|
||||
page_filter: PostFilter,
|
||||
media_filter: MediaFilter,
|
||||
|
||||
// Navigation
|
||||
sidebar_view: SidebarView,
|
||||
sidebar_visible: bool,
|
||||
@@ -252,6 +275,9 @@ impl BdsApp {
|
||||
sidebar_media: Vec::new(),
|
||||
sidebar_scripts: Vec::new(),
|
||||
sidebar_templates: Vec::new(),
|
||||
post_filter: PostFilter::default(),
|
||||
page_filter: PostFilter::default(),
|
||||
media_filter: MediaFilter::default(),
|
||||
sidebar_view: SidebarView::Posts,
|
||||
sidebar_visible: true,
|
||||
sidebar_width: 280.0,
|
||||
@@ -292,8 +318,18 @@ impl BdsApp {
|
||||
Message::SetActiveView(view) => {
|
||||
let (new_view, new_visible) =
|
||||
handle_activity_click(self.sidebar_view, self.sidebar_visible, view);
|
||||
let old_view = self.sidebar_view;
|
||||
self.sidebar_view = new_view;
|
||||
self.sidebar_visible = new_visible;
|
||||
// When switching between Posts/Pages, re-query with correct filter
|
||||
let switched_post_scope = matches!(
|
||||
(old_view, new_view),
|
||||
(SidebarView::Posts, SidebarView::Pages)
|
||||
| (SidebarView::Pages, SidebarView::Posts)
|
||||
);
|
||||
if switched_post_scope {
|
||||
self.refresh_sidebar_posts();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleSidebar => {
|
||||
@@ -691,6 +727,113 @@ impl BdsApp {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Sidebar filters ──
|
||||
Message::PostSearchChanged(query) => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.search_query = query;
|
||||
self.refresh_sidebar_posts();
|
||||
Task::none()
|
||||
}
|
||||
Message::TogglePostFilterPanel => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.filter_panel_visible = !filter.filter_panel_visible;
|
||||
Task::none()
|
||||
}
|
||||
Message::SetPostCalendarYear(year) => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.calendar.selected_year = year;
|
||||
filter.calendar.selected_month = None;
|
||||
self.refresh_sidebar_posts();
|
||||
Task::none()
|
||||
}
|
||||
Message::SetPostCalendarMonth(month) => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.calendar.selected_month = month;
|
||||
self.refresh_sidebar_posts();
|
||||
Task::none()
|
||||
}
|
||||
Message::TogglePostTagFilter(tag) => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
if let Some(pos) = filter.tag_filter.iter().position(|t| *t == tag) {
|
||||
filter.tag_filter.remove(pos);
|
||||
} else {
|
||||
filter.tag_filter.push(tag);
|
||||
}
|
||||
self.refresh_sidebar_posts();
|
||||
Task::none()
|
||||
}
|
||||
Message::TogglePostCategoryFilter(cat) => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
if let Some(pos) = filter.category_filter.iter().position(|c| *c == cat) {
|
||||
filter.category_filter.remove(pos);
|
||||
} else {
|
||||
filter.category_filter.push(cat);
|
||||
}
|
||||
self.refresh_sidebar_posts();
|
||||
Task::none()
|
||||
}
|
||||
Message::ClearPostFilters => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.clear();
|
||||
self.refresh_sidebar_posts();
|
||||
Task::none()
|
||||
}
|
||||
Message::MediaSearchChanged(query) => {
|
||||
self.media_filter.search_query = query;
|
||||
self.refresh_sidebar_media();
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleMediaFilterPanel => {
|
||||
self.media_filter.filter_panel_visible = !self.media_filter.filter_panel_visible;
|
||||
Task::none()
|
||||
}
|
||||
Message::SetMediaCalendarYear(year) => {
|
||||
self.media_filter.calendar.selected_year = year;
|
||||
self.media_filter.calendar.selected_month = None;
|
||||
self.refresh_sidebar_media();
|
||||
Task::none()
|
||||
}
|
||||
Message::SetMediaCalendarMonth(month) => {
|
||||
self.media_filter.calendar.selected_month = month;
|
||||
self.refresh_sidebar_media();
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleMediaTagFilter(tag) => {
|
||||
if let Some(pos) = self.media_filter.tag_filter.iter().position(|t| *t == tag) {
|
||||
self.media_filter.tag_filter.remove(pos);
|
||||
} else {
|
||||
self.media_filter.tag_filter.push(tag);
|
||||
}
|
||||
self.refresh_sidebar_media();
|
||||
Task::none()
|
||||
}
|
||||
Message::ClearMediaFilters => {
|
||||
self.media_filter.clear();
|
||||
self.refresh_sidebar_media();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Modal ──
|
||||
Message::ShowModal(state) => {
|
||||
self.active_modal = Some(state);
|
||||
@@ -736,6 +879,10 @@ impl BdsApp {
|
||||
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
let active_name = self.active_project.as_ref().map(|p| p.name.as_str());
|
||||
let active_post_filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &self.page_filter,
|
||||
_ => &self.post_filter,
|
||||
};
|
||||
|
||||
workspace::view(
|
||||
self.sidebar_view,
|
||||
@@ -751,6 +898,8 @@ impl BdsApp {
|
||||
&self.sidebar_media,
|
||||
&self.sidebar_scripts,
|
||||
&self.sidebar_templates,
|
||||
active_post_filter,
|
||||
&self.media_filter,
|
||||
active_name,
|
||||
&self.projects,
|
||||
self.active_project.as_ref().map(|p| p.id.as_str()),
|
||||
@@ -1053,20 +1202,7 @@ impl BdsApp {
|
||||
&project.id,
|
||||
)
|
||||
.unwrap_or(0) as usize;
|
||||
self.sidebar_posts = bds_core::db::queries::post::list_posts_by_project_limited(
|
||||
db.conn(),
|
||||
&project.id,
|
||||
500,
|
||||
0,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
self.sidebar_media = bds_core::db::queries::media::list_media_by_project_limited(
|
||||
db.conn(),
|
||||
&project.id,
|
||||
500,
|
||||
0,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
self.sidebar_scripts = bds_core::db::queries::script::list_scripts_by_project(
|
||||
db.conn(),
|
||||
&project.id,
|
||||
@@ -1077,6 +1213,7 @@ impl BdsApp {
|
||||
&project.id,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Read pico theme from project metadata for status bar badge
|
||||
if let Some(ref data_dir) = self.data_dir {
|
||||
if let Ok(meta) = engine::meta::read_project_json(data_dir) {
|
||||
@@ -1086,6 +1223,111 @@ impl BdsApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh sidebar data with current filters (separate borrows to avoid borrow conflict)
|
||||
self.refresh_sidebar_posts();
|
||||
self.refresh_sidebar_media();
|
||||
self.refresh_filter_metadata();
|
||||
}
|
||||
|
||||
/// Refresh only sidebar posts using current filter state.
|
||||
fn refresh_sidebar_posts(&mut self) {
|
||||
if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
||||
use bds_core::db::queries::post::{PostFilterParams, list_posts_filtered};
|
||||
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &self.page_filter,
|
||||
_ => &self.post_filter,
|
||||
};
|
||||
let is_pages = self.sidebar_view == SidebarView::Pages;
|
||||
|
||||
let params = PostFilterParams {
|
||||
search_query: filter.search_query.clone(),
|
||||
year: filter.calendar.selected_year,
|
||||
month: filter.calendar.selected_month,
|
||||
tags: filter.tag_filter.clone(),
|
||||
categories: filter.category_filter.clone(),
|
||||
exclude_pages: !is_pages,
|
||||
pages_only: is_pages,
|
||||
};
|
||||
|
||||
self.sidebar_posts = list_posts_filtered(
|
||||
db.conn(), &project.id, ¶ms, 500, 0,
|
||||
).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh only sidebar media using current filter state.
|
||||
fn refresh_sidebar_media(&mut self) {
|
||||
if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
||||
use bds_core::db::queries::media::{MediaFilterParams, list_media_filtered};
|
||||
|
||||
let params = MediaFilterParams {
|
||||
search_query: self.media_filter.search_query.clone(),
|
||||
year: self.media_filter.calendar.selected_year,
|
||||
month: self.media_filter.calendar.selected_month,
|
||||
tags: self.media_filter.tag_filter.clone(),
|
||||
};
|
||||
|
||||
self.sidebar_media = list_media_filtered(
|
||||
db.conn(), &project.id, ¶ms, 500, 0,
|
||||
).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh available tags, categories, and calendar data for filter widgets.
|
||||
fn refresh_filter_metadata(&mut self) {
|
||||
if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
||||
use bds_core::db::queries::post;
|
||||
use bds_core::db::queries::media;
|
||||
|
||||
// Post filter metadata
|
||||
let all_tags = post::distinct_post_tags(db.conn(), &project.id)
|
||||
.unwrap_or_default();
|
||||
let all_cats = post::distinct_post_categories(db.conn(), &project.id)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Calendar counts for posts (excluding pages)
|
||||
let post_cal = post::post_calendar_counts(
|
||||
db.conn(), &project.id, false, true,
|
||||
).unwrap_or_default();
|
||||
self.post_filter.available_tags = all_tags.clone();
|
||||
self.post_filter.available_categories = all_cats.clone();
|
||||
self.post_filter.calendar_years = Self::build_calendar_tree(&post_cal);
|
||||
|
||||
// Calendar counts for pages only
|
||||
let page_cal = post::post_calendar_counts(
|
||||
db.conn(), &project.id, true, false,
|
||||
).unwrap_or_default();
|
||||
self.page_filter.available_tags = all_tags;
|
||||
self.page_filter.available_categories = all_cats;
|
||||
self.page_filter.calendar_years = Self::build_calendar_tree(&page_cal);
|
||||
|
||||
// Media filter metadata
|
||||
self.media_filter.available_tags = media::distinct_media_tags(
|
||||
db.conn(), &project.id,
|
||||
).unwrap_or_default();
|
||||
let media_cal = media::media_calendar_counts(
|
||||
db.conn(), &project.id,
|
||||
).unwrap_or_default();
|
||||
self.media_filter.calendar_years = Self::build_calendar_tree(&media_cal);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert (year, month, count) tuples into CalendarYear/CalendarMonth tree.
|
||||
fn build_calendar_tree(data: &[(i32, u32, usize)]) -> Vec<CalendarYear> {
|
||||
let mut years: Vec<CalendarYear> = Vec::new();
|
||||
for &(y, m, c) in data {
|
||||
if let Some(cy) = years.iter_mut().find(|cy| cy.year == y) {
|
||||
cy.months.push(CalendarMonth { month: m, count: c });
|
||||
} else {
|
||||
years.push(CalendarYear {
|
||||
year: y,
|
||||
months: vec![CalendarMonth { month: m, count: c }],
|
||||
});
|
||||
}
|
||||
}
|
||||
years
|
||||
}
|
||||
|
||||
/// Per layout.allium PanelTabFallback invariant: if the active panel tab
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod navigation;
|
||||
pub mod sidebar_filter;
|
||||
pub mod tabs;
|
||||
pub mod toast;
|
||||
|
||||
pub use navigation::{SidebarView, PanelTab, TaskSnapshot, OutputEntry};
|
||||
pub use sidebar_filter::{PostFilter, MediaFilter, CalendarFilter, CalendarYear, CalendarMonth};
|
||||
pub use tabs::{Tab, TabType};
|
||||
pub use toast::{Toast, ToastLevel};
|
||||
|
||||
101
crates/bds-ui/src/state/sidebar_filter.rs
Normal file
101
crates/bds-ui/src/state/sidebar_filter.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
/// Sidebar filter state per sidebar_views.allium PostsView / MediaView.
|
||||
///
|
||||
/// Per ui_data_flow.allium SidebarFilterIsolation:
|
||||
/// "Sidebar search/filter state is local to the sidebar component.
|
||||
/// Filtering never affects: active tab, editor content, selectedPostId.
|
||||
/// Only the visible list of items changes."
|
||||
|
||||
/// Calendar year/month archive filter.
|
||||
/// Per sidebar_views.allium CalendarFilter.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CalendarFilter {
|
||||
pub selected_year: Option<i32>,
|
||||
pub selected_month: Option<u32>, // 1-12
|
||||
}
|
||||
|
||||
/// A year in the calendar archive tree, with per-month counts.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CalendarYear {
|
||||
pub year: i32,
|
||||
pub months: Vec<CalendarMonth>,
|
||||
}
|
||||
|
||||
/// A month in the calendar archive tree.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CalendarMonth {
|
||||
pub month: u32, // 1-12
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// Filter state for the Posts sidebar (and Pages, which is Posts with
|
||||
/// category_filter pre-set to ["page"]).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PostFilter {
|
||||
/// FTS search query, per sidebar_views.allium PostsView.search_query.
|
||||
pub search_query: String,
|
||||
/// Whether the collapsible filter panel is visible.
|
||||
pub filter_panel_visible: bool,
|
||||
/// Year/month archive filter.
|
||||
pub calendar: CalendarFilter,
|
||||
/// Selected tag names (multi-select).
|
||||
pub tag_filter: Vec<String>,
|
||||
/// Selected category names (multi-select).
|
||||
pub category_filter: Vec<String>,
|
||||
/// Calendar tree for the toggle widget.
|
||||
pub calendar_years: Vec<CalendarYear>,
|
||||
/// All available tags for the chip selector.
|
||||
pub available_tags: Vec<String>,
|
||||
/// All available categories for the chip selector.
|
||||
pub available_categories: Vec<String>,
|
||||
}
|
||||
|
||||
/// Filter state for the Media sidebar.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MediaFilter {
|
||||
/// FTS search query.
|
||||
pub search_query: String,
|
||||
/// Whether the collapsible filter panel is visible.
|
||||
pub filter_panel_visible: bool,
|
||||
/// Year/month archive filter.
|
||||
pub calendar: CalendarFilter,
|
||||
/// Selected tag names (multi-select).
|
||||
pub tag_filter: Vec<String>,
|
||||
/// Calendar tree for the toggle widget.
|
||||
pub calendar_years: Vec<CalendarYear>,
|
||||
/// All available tags for the chip selector.
|
||||
pub available_tags: Vec<String>,
|
||||
}
|
||||
|
||||
impl PostFilter {
|
||||
/// Returns true if any filter is active (for "Clear All" visibility).
|
||||
pub fn has_active_filters(&self) -> bool {
|
||||
!self.search_query.is_empty()
|
||||
|| self.calendar.selected_year.is_some()
|
||||
|| !self.tag_filter.is_empty()
|
||||
|| !self.category_filter.is_empty()
|
||||
}
|
||||
|
||||
/// Reset all filters to defaults.
|
||||
pub fn clear(&mut self) {
|
||||
self.search_query.clear();
|
||||
self.calendar = CalendarFilter::default();
|
||||
self.tag_filter.clear();
|
||||
self.category_filter.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaFilter {
|
||||
/// Returns true if any filter is active.
|
||||
pub fn has_active_filters(&self) -> bool {
|
||||
!self.search_query.is_empty()
|
||||
|| self.calendar.selected_year.is_some()
|
||||
|| !self.tag_filter.is_empty()
|
||||
}
|
||||
|
||||
/// Reset all filters to defaults.
|
||||
pub fn clear(&mut self) {
|
||||
self.search_query.clear();
|
||||
self.calendar = CalendarFilter::default();
|
||||
self.tag_filter.clear();
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -7,6 +7,7 @@ use bds_core::model::{Media, Post, Project, Script, Template};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot};
|
||||
use crate::state::sidebar_filter::{PostFilter, MediaFilter};
|
||||
use crate::state::tabs::{Tab, TabType};
|
||||
use crate::state::toast::Toast;
|
||||
use crate::views::{activity_bar, modal, panel, project_selector, sidebar, status_bar, tab_bar, toast, welcome};
|
||||
@@ -57,6 +58,9 @@ pub fn view(
|
||||
sidebar_media: &[Media],
|
||||
sidebar_scripts: &[Script],
|
||||
sidebar_templates: &[Template],
|
||||
// Sidebar filters
|
||||
post_filter: &PostFilter,
|
||||
media_filter: &MediaFilter,
|
||||
// Status bar
|
||||
active_project_name: Option<&str>,
|
||||
projects: &[Project],
|
||||
@@ -110,7 +114,7 @@ pub fn view(
|
||||
let mut main_row = row![activity];
|
||||
|
||||
if sidebar_visible {
|
||||
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_scripts, sidebar_templates, sidebar_width, active_tab, locale));
|
||||
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_scripts, sidebar_templates, post_filter, media_filter, sidebar_width, active_tab, locale));
|
||||
|
||||
// Resize drag handle: 4px wide strip between sidebar and content
|
||||
let handle = container(Space::new(0, 0))
|
||||
|
||||
@@ -164,5 +164,12 @@
|
||||
"modal.confirmDelete.delete": "Löschen",
|
||||
"modal.confirm.title": "Bestätigen",
|
||||
"modal.confirm.cancel": "Abbrechen",
|
||||
"modal.confirm.confirm": "Bestätigen"
|
||||
"modal.confirm.confirm": "Bestätigen",
|
||||
"sidebar.filter.search": "Suchen...",
|
||||
"sidebar.filter.toggle": "Filter",
|
||||
"sidebar.filter.clearAll": "Alle Filter zurücksetzen",
|
||||
"sidebar.filter.tags": "Tags",
|
||||
"sidebar.filter.categories": "Kategorien",
|
||||
"sidebar.filter.calendar": "Archiv",
|
||||
"sidebar.filter.noResults": "Keine Treffer"
|
||||
}
|
||||
|
||||
@@ -164,5 +164,12 @@
|
||||
"modal.confirmDelete.delete": "Delete",
|
||||
"modal.confirm.title": "Confirm",
|
||||
"modal.confirm.cancel": "Cancel",
|
||||
"modal.confirm.confirm": "Confirm"
|
||||
"modal.confirm.confirm": "Confirm",
|
||||
"sidebar.filter.search": "Search...",
|
||||
"sidebar.filter.toggle": "Filters",
|
||||
"sidebar.filter.clearAll": "Clear All Filters",
|
||||
"sidebar.filter.tags": "Tags",
|
||||
"sidebar.filter.categories": "Categories",
|
||||
"sidebar.filter.calendar": "Archive",
|
||||
"sidebar.filter.noResults": "No matching items"
|
||||
}
|
||||
|
||||
@@ -164,5 +164,12 @@
|
||||
"modal.confirmDelete.delete": "Eliminar",
|
||||
"modal.confirm.title": "Confirmar",
|
||||
"modal.confirm.cancel": "Cancelar",
|
||||
"modal.confirm.confirm": "Confirmar"
|
||||
"modal.confirm.confirm": "Confirmar",
|
||||
"sidebar.filter.search": "Buscar...",
|
||||
"sidebar.filter.toggle": "Filtros",
|
||||
"sidebar.filter.clearAll": "Borrar todos los filtros",
|
||||
"sidebar.filter.tags": "Etiquetas",
|
||||
"sidebar.filter.categories": "Categorías",
|
||||
"sidebar.filter.calendar": "Archivo",
|
||||
"sidebar.filter.noResults": "Sin resultados"
|
||||
}
|
||||
|
||||
@@ -164,5 +164,12 @@
|
||||
"modal.confirmDelete.delete": "Supprimer",
|
||||
"modal.confirm.title": "Confirmer",
|
||||
"modal.confirm.cancel": "Annuler",
|
||||
"modal.confirm.confirm": "Confirmer"
|
||||
"modal.confirm.confirm": "Confirmer",
|
||||
"sidebar.filter.search": "Rechercher...",
|
||||
"sidebar.filter.toggle": "Filtres",
|
||||
"sidebar.filter.clearAll": "Effacer tous les filtres",
|
||||
"sidebar.filter.tags": "Tags",
|
||||
"sidebar.filter.categories": "Catégories",
|
||||
"sidebar.filter.calendar": "Archives",
|
||||
"sidebar.filter.noResults": "Aucun résultat"
|
||||
}
|
||||
|
||||
@@ -164,5 +164,12 @@
|
||||
"modal.confirmDelete.delete": "Elimina",
|
||||
"modal.confirm.title": "Conferma",
|
||||
"modal.confirm.cancel": "Annulla",
|
||||
"modal.confirm.confirm": "Conferma"
|
||||
"modal.confirm.confirm": "Conferma",
|
||||
"sidebar.filter.search": "Cerca...",
|
||||
"sidebar.filter.toggle": "Filtri",
|
||||
"sidebar.filter.clearAll": "Cancella tutti i filtri",
|
||||
"sidebar.filter.tags": "Tag",
|
||||
"sidebar.filter.categories": "Categorie",
|
||||
"sidebar.filter.calendar": "Archivio",
|
||||
"sidebar.filter.noResults": "Nessun risultato"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user