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

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

View File

@@ -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::*;

View File

@@ -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, &params, 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, &params, 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

View File

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

View 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();
}
}

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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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