feat: filtering in sidebars
This commit is contained in:
@@ -13,38 +13,52 @@ defmodule BDS.UI.Sidebar do
|
||||
alias BDS.Templates.Template
|
||||
|
||||
@page_category "page"
|
||||
@default_page_size 500
|
||||
|
||||
def snapshot(nil), do: empty_snapshot()
|
||||
|
||||
def snapshot(project_id) when is_binary(project_id) do
|
||||
posts = list_posts(project_id)
|
||||
translation_counts = translation_counts(project_id)
|
||||
media_items = list_media(project_id)
|
||||
scripts = list_scripts(project_id)
|
||||
templates = list_templates(project_id)
|
||||
tags = list_tags(project_id)
|
||||
conversations = list_conversations()
|
||||
|
||||
%{
|
||||
"posts" => posts_view(posts, translation_counts, false),
|
||||
"pages" => posts_view(posts, translation_counts, true),
|
||||
"media" => media_view(media_items),
|
||||
"scripts" => entity_list_view("Scripts", "Automation helpers", "scripts", scripts),
|
||||
"templates" =>
|
||||
entity_list_view("Templates", "Site rendering", "templates", templates),
|
||||
"tags" => tags_nav_view(tags),
|
||||
"chat" => entity_list_view("Chat", "AI conversations", "chat", conversations),
|
||||
"posts" => view(project_id, "posts"),
|
||||
"pages" => view(project_id, "pages"),
|
||||
"media" => view(project_id, "media"),
|
||||
"scripts" => view(project_id, "scripts"),
|
||||
"templates" => view(project_id, "templates"),
|
||||
"tags" => view(project_id, "tags"),
|
||||
"chat" => view(project_id, "chat"),
|
||||
"import" => entity_list_view("Import", "Import definitions", "import", []),
|
||||
"git" => git_view(),
|
||||
"settings" => settings_nav_view()
|
||||
}
|
||||
end
|
||||
|
||||
def view(project_id, view_id, params \\ %{})
|
||||
|
||||
def view(nil, view_id, _params), do: empty_view(view_id)
|
||||
|
||||
def view(project_id, view_id, params) when is_binary(project_id) do
|
||||
normalized_view = normalize_view_id(view_id)
|
||||
|
||||
case normalized_view do
|
||||
"posts" -> posts_view(project_id, params, false)
|
||||
"pages" -> posts_view(project_id, params, true)
|
||||
"media" -> media_view(project_id, params)
|
||||
"scripts" -> entity_list_view("Scripts", "Automation helpers", "scripts", list_scripts(project_id))
|
||||
"templates" -> entity_list_view("Templates", "Site rendering", "templates", list_templates(project_id))
|
||||
"tags" -> tags_nav_view(list_tags(project_id))
|
||||
"chat" -> entity_list_view("Chat", "AI conversations", "chat", list_conversations())
|
||||
"import" -> entity_list_view("Import", "Import definitions", "import", [])
|
||||
"git" -> git_view()
|
||||
"settings" -> settings_nav_view()
|
||||
_other -> empty_view(normalized_view)
|
||||
end
|
||||
end
|
||||
|
||||
def empty_snapshot do
|
||||
%{
|
||||
"posts" => posts_view([], %{}, false),
|
||||
"pages" => posts_view([], %{}, true),
|
||||
"media" => media_view([]),
|
||||
"posts" => empty_view("posts"),
|
||||
"pages" => empty_view("pages"),
|
||||
"media" => empty_view("media"),
|
||||
"scripts" => entity_list_view("Scripts", "Automation helpers", "scripts", []),
|
||||
"templates" => entity_list_view("Templates", "Site rendering", "templates", []),
|
||||
"tags" => tags_nav_view([]),
|
||||
@@ -55,15 +69,67 @@ defmodule BDS.UI.Sidebar do
|
||||
}
|
||||
end
|
||||
|
||||
defp posts_view(posts, translation_counts, pages?) do
|
||||
filtered_posts = Enum.filter(posts, &(page_post?(&1) == pages?))
|
||||
grouped_posts = group_posts(filtered_posts)
|
||||
defp empty_view("posts"), do: posts_view_data([], [], %{}, false, empty_filter_params())
|
||||
defp empty_view("pages"), do: posts_view_data([], [], %{}, true, empty_filter_params())
|
||||
defp empty_view("media"), do: media_view_data([], [], empty_filter_params())
|
||||
defp empty_view("scripts"), do: entity_list_view("Scripts", "Automation helpers", "scripts", [])
|
||||
defp empty_view("templates"), do: entity_list_view("Templates", "Site rendering", "templates", [])
|
||||
defp empty_view("tags"), do: tags_nav_view([])
|
||||
defp empty_view("chat"), do: entity_list_view("Chat", "AI conversations", "chat", [])
|
||||
defp empty_view("import"), do: entity_list_view("Import", "Import definitions", "import", [])
|
||||
defp empty_view("git"), do: git_view()
|
||||
defp empty_view("settings"), do: settings_nav_view()
|
||||
defp empty_view(_other), do: %{title: "", subtitle: "", layout: "entity_list", items: [], empty_message: "No items"}
|
||||
|
||||
defp posts_view(project_id, params, pages?) do
|
||||
posts = list_posts(project_id)
|
||||
translation_counts = translation_counts(project_id)
|
||||
filters = normalize_filter_params(params)
|
||||
base_posts = Enum.filter(posts, &(page_post?(&1) == pages?))
|
||||
filtered_posts = apply_post_filters(base_posts, filters)
|
||||
|
||||
posts_view_data(base_posts, filtered_posts, translation_counts, pages?, filters)
|
||||
end
|
||||
|
||||
defp posts_view_data(base_posts, filtered_posts, translation_counts, pages?, filters) do
|
||||
limited_posts = Enum.take(filtered_posts, filters.display_limit)
|
||||
grouped_posts = group_posts(limited_posts)
|
||||
|
||||
%{
|
||||
title: if(pages?, do: "Pages", else: "Posts"),
|
||||
subtitle: if(pages?, do: "Standalone pages", else: "Drafts, published entries, and archive history"),
|
||||
layout: "post_list",
|
||||
empty_message: if(pages?, do: "No items", else: "No items"),
|
||||
empty_message: if(pages?, do: "sidebar.noPagesYet", else: "sidebar.noPostsYet"),
|
||||
filters: %{
|
||||
enabled: true,
|
||||
search_placeholder: if(pages?, do: "sidebar.searchPagesPlaceholder", else: "sidebar.searchPostsPlaceholder"),
|
||||
toggle_filters_label: "sidebar.toggleFilters",
|
||||
archive_label: "render.archive",
|
||||
tags_label: "sidebar.tags",
|
||||
categories_label: "sidebar.categories",
|
||||
clear_tags_label: "sidebar.clearTags",
|
||||
clear_categories_label: "sidebar.clearCategories",
|
||||
clear_filters_label: "sidebar.clearFilters",
|
||||
results_label: "sidebar.results",
|
||||
results_for_label: "sidebar.resultsFor",
|
||||
no_results_label: "sidebar.noMatchingPosts",
|
||||
year_month_counts: year_month_counts(base_posts, &post_filter_timestamp/1),
|
||||
available_tags: available_tags(base_posts, & &1.tags),
|
||||
available_categories: available_categories(base_posts, pages?),
|
||||
max_items: @default_page_size,
|
||||
display_limit: filters.display_limit,
|
||||
loaded_count: length(limited_posts),
|
||||
total_count: length(filtered_posts),
|
||||
has_more: length(filtered_posts) > filters.display_limit,
|
||||
has_active_filters: filter_active?(filters),
|
||||
selected: %{
|
||||
search: filters.search,
|
||||
year: filters.year,
|
||||
month: filters.month,
|
||||
tags: filters.tags,
|
||||
categories: filters.categories
|
||||
}
|
||||
},
|
||||
sections: [
|
||||
build_post_section("Drafts", :draft, grouped_posts.draft, translation_counts, false),
|
||||
build_post_section("Published", :published, grouped_posts.published, translation_counts, true),
|
||||
@@ -72,20 +138,61 @@ defmodule BDS.UI.Sidebar do
|
||||
}
|
||||
end
|
||||
|
||||
defp media_view(media_items) do
|
||||
defp media_view(project_id, params) do
|
||||
media_items = list_media(project_id)
|
||||
filters = normalize_filter_params(params)
|
||||
filtered_media = apply_media_filters(media_items, filters)
|
||||
|
||||
media_view_data(media_items, filtered_media, filters)
|
||||
end
|
||||
|
||||
defp media_view_data(base_media, filtered_media, filters) do
|
||||
limited_media = Enum.take(filtered_media, filters.display_limit)
|
||||
|
||||
%{
|
||||
title: "Media",
|
||||
subtitle: "Images and files",
|
||||
layout: "media_grid",
|
||||
empty_message: "No items",
|
||||
empty_message: "sidebar.noMediaFiles",
|
||||
filters: %{
|
||||
enabled: true,
|
||||
search_placeholder: "sidebar.searchMediaPlaceholder",
|
||||
toggle_filters_label: "sidebar.toggleFilters",
|
||||
archive_label: "render.archive",
|
||||
tags_label: "sidebar.tags",
|
||||
clear_tags_label: "sidebar.clearTags",
|
||||
clear_filters_label: "sidebar.clearFilters",
|
||||
results_label: "sidebar.results",
|
||||
results_for_label: "sidebar.resultsFor",
|
||||
no_results_label: "sidebar.noMediaFiles",
|
||||
year_month_counts: year_month_counts(base_media, &Map.get(&1, :updated_at)),
|
||||
available_tags: available_tags(base_media, & &1.tags),
|
||||
available_categories: [],
|
||||
max_items: @default_page_size,
|
||||
display_limit: filters.display_limit,
|
||||
loaded_count: length(limited_media),
|
||||
total_count: length(filtered_media),
|
||||
has_more: length(filtered_media) > filters.display_limit,
|
||||
has_active_filters: filter_active?(filters),
|
||||
selected: %{
|
||||
search: filters.search,
|
||||
year: filters.year,
|
||||
month: filters.month,
|
||||
tags: filters.tags,
|
||||
categories: []
|
||||
}
|
||||
},
|
||||
items:
|
||||
Enum.map(media_items, fn media ->
|
||||
Enum.map(limited_media, fn media ->
|
||||
%{
|
||||
id: media.id,
|
||||
title: display_media_title(media),
|
||||
meta: media_size_label(media.size),
|
||||
mime_type: media.mime_type,
|
||||
route: "media"
|
||||
route: "media",
|
||||
updated_at: media.updated_at,
|
||||
tags: media.tags || [],
|
||||
search_blob: media_search_blob(media)
|
||||
}
|
||||
end)
|
||||
}
|
||||
@@ -167,9 +274,12 @@ defmodule BDS.UI.Sidebar do
|
||||
id: post.id,
|
||||
title: display_post_title(post),
|
||||
categories: post.categories || [],
|
||||
tags: post.tags || [],
|
||||
status: Atom.to_string(post.status),
|
||||
language_count: 1 + Map.get(translation_counts, post.id, 0),
|
||||
meta_timestamp: if(published_meta?, do: post.published_at || post.updated_at, else: post.updated_at),
|
||||
route: "post"
|
||||
route: "post",
|
||||
search_blob: post_search_blob(post)
|
||||
}
|
||||
end)
|
||||
}
|
||||
@@ -184,10 +294,13 @@ defmodule BDS.UI.Sidebar do
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
status: post.status,
|
||||
tags: post.tags,
|
||||
categories: post.categories,
|
||||
updated_at: post.updated_at,
|
||||
published_at: post.published_at
|
||||
published_at: post.published_at,
|
||||
language: post.language
|
||||
}
|
||||
)
|
||||
end
|
||||
@@ -212,7 +325,11 @@ defmodule BDS.UI.Sidebar do
|
||||
title: media.title,
|
||||
original_name: media.original_name,
|
||||
mime_type: media.mime_type,
|
||||
size: media.size
|
||||
size: media.size,
|
||||
tags: media.tags,
|
||||
alt: media.alt,
|
||||
caption: media.caption,
|
||||
updated_at: media.updated_at
|
||||
}
|
||||
)
|
||||
end
|
||||
@@ -267,6 +384,162 @@ defmodule BDS.UI.Sidebar do
|
||||
Enum.any?(post.categories || [], &(String.downcase(to_string(&1)) == @page_category))
|
||||
end
|
||||
|
||||
defp normalize_view_id(view_id) when is_atom(view_id), do: Atom.to_string(view_id)
|
||||
defp normalize_view_id(view_id) when is_binary(view_id), do: view_id
|
||||
defp normalize_view_id(_other), do: ""
|
||||
|
||||
defp normalize_filter_params(params) when is_map(params) do
|
||||
%{
|
||||
search: normalize_string(Map.get(params, "search") || Map.get(params, :search)),
|
||||
year: normalize_integer(Map.get(params, "year") || Map.get(params, :year)),
|
||||
month: normalize_integer(Map.get(params, "month") || Map.get(params, :month)),
|
||||
tags: normalize_string_list(Map.get(params, "tags") || Map.get(params, :tags)),
|
||||
categories: normalize_string_list(Map.get(params, "categories") || Map.get(params, :categories)),
|
||||
display_limit:
|
||||
max(
|
||||
@default_page_size,
|
||||
normalize_integer(Map.get(params, "display_limit") || Map.get(params, :display_limit)) || @default_page_size
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_filter_params(_params), do: empty_filter_params()
|
||||
|
||||
defp empty_filter_params do
|
||||
%{search: nil, year: nil, month: nil, tags: [], categories: [], display_limit: @default_page_size}
|
||||
end
|
||||
|
||||
defp filter_active?(filters) do
|
||||
present?(filters.search) or not is_nil(filters.year) or filters.tags != [] or filters.categories != []
|
||||
end
|
||||
|
||||
defp apply_post_filters(posts, filters) do
|
||||
Enum.filter(posts, fn post ->
|
||||
matches_search?(post_search_blob(post), filters.search) and
|
||||
matches_year_month?(post_filter_timestamp(post), filters.year, filters.month) and
|
||||
matches_overlap?(post.tags, filters.tags) and
|
||||
matches_overlap?(filtered_categories(post.categories), filters.categories)
|
||||
end)
|
||||
end
|
||||
|
||||
defp apply_media_filters(media_items, filters) do
|
||||
Enum.filter(media_items, fn media ->
|
||||
matches_search?(media_search_blob(media), filters.search) and
|
||||
matches_year_month?(media.updated_at, filters.year, filters.month) and
|
||||
matches_overlap?(media.tags, filters.tags)
|
||||
end)
|
||||
end
|
||||
|
||||
defp matches_search?(_text, nil), do: true
|
||||
|
||||
defp matches_search?(text, search) do
|
||||
String.contains?(String.downcase(text), String.downcase(search))
|
||||
end
|
||||
|
||||
defp matches_year_month?(_timestamp, nil, _month), do: true
|
||||
defp matches_year_month?(nil, _year, _month), do: false
|
||||
|
||||
defp matches_year_month?(timestamp, year, month) do
|
||||
datetime = DateTime.from_unix!(timestamp, :millisecond)
|
||||
|
||||
datetime.year == year and
|
||||
(is_nil(month) or datetime.month == month)
|
||||
end
|
||||
|
||||
defp matches_overlap?(_values, []), do: true
|
||||
|
||||
defp matches_overlap?(values, filters) do
|
||||
normalized_values = MapSet.new(Enum.map(values || [], &normalize_term/1))
|
||||
|
||||
Enum.all?(filters, fn filter ->
|
||||
MapSet.member?(normalized_values, normalize_term(filter))
|
||||
end)
|
||||
end
|
||||
|
||||
defp year_month_counts(items, timestamp_fun) do
|
||||
items
|
||||
|> Enum.reduce(%{}, fn item, acc ->
|
||||
case timestamp_fun.(item) do
|
||||
timestamp when is_integer(timestamp) ->
|
||||
datetime = DateTime.from_unix!(timestamp, :millisecond)
|
||||
Map.update(acc, {datetime.year, datetime.month}, 1, &(&1 + 1))
|
||||
|
||||
_other ->
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|> Enum.map(fn {{year, month}, count} -> %{year: year, month: month, count: count} end)
|
||||
|> Enum.sort_by(fn entry -> {-entry.year, -entry.month} end)
|
||||
end
|
||||
|
||||
defp available_tags(items, getter) do
|
||||
items
|
||||
|> Enum.flat_map(fn item -> getter.(item) || [] end)
|
||||
|> Enum.map(&to_string/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|> Enum.uniq_by(&String.downcase/1)
|
||||
|> Enum.sort_by(&String.downcase/1)
|
||||
end
|
||||
|
||||
defp available_categories(posts, pages?) do
|
||||
posts
|
||||
|> Enum.flat_map(&filtered_categories(&1.categories || []))
|
||||
|> then(fn categories ->
|
||||
if pages?, do: Enum.reject(categories, &(normalize_term(&1) == @page_category)), else: categories
|
||||
end)
|
||||
|> Enum.map(&to_string/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|> Enum.uniq_by(&String.downcase/1)
|
||||
|> Enum.sort_by(&String.downcase/1)
|
||||
end
|
||||
|
||||
defp filtered_categories(categories) do
|
||||
Enum.reject(categories || [], &(normalize_term(&1) == @page_category))
|
||||
end
|
||||
|
||||
defp post_filter_timestamp(post), do: post.published_at || post.updated_at
|
||||
|
||||
defp post_search_blob(post) do
|
||||
[post.title, post.slug, post.excerpt, Enum.join(post.tags || [], " "), Enum.join(post.categories || [], " ")]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
|
||||
defp media_search_blob(media) do
|
||||
[media.title, media.original_name, media.alt, media.caption, Enum.join(media.tags || [], " ")]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
|
||||
defp normalize_integer(nil), do: nil
|
||||
defp normalize_integer(value) when is_integer(value), do: value
|
||||
|
||||
defp normalize_integer(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{integer, ""} -> integer
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_integer(_value), do: nil
|
||||
|
||||
defp normalize_string(value) when is_binary(value) do
|
||||
trimmed = String.trim(value)
|
||||
if trimmed == "", do: nil, else: trimmed
|
||||
end
|
||||
|
||||
defp normalize_string(_value), do: nil
|
||||
|
||||
defp normalize_string_list(values) when is_list(values) do
|
||||
values
|
||||
|> Enum.map(&normalize_string/1)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
defp normalize_string_list(_values), do: []
|
||||
|
||||
defp normalize_term(value), do: value |> to_string() |> String.downcase()
|
||||
|
||||
defp display_post_title(post) do
|
||||
cond do
|
||||
present?(post.title) -> post.title
|
||||
|
||||
Reference in New Issue
Block a user