feat: filtering in sidebars

This commit is contained in:
2026-04-25 20:51:01 +02:00
parent 55b3071696
commit cec170c9c0
11 changed files with 1125 additions and 33 deletions

View File

@@ -42,6 +42,15 @@ defmodule BDS.Desktop.Router do
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.projects_json()) |> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.projects_json())
end end
post "/api/sidebar" do
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.sidebar_json(payload))
end
post "/api/projects" do post "/api/projects" do
{:ok, body, conn} = Plug.Conn.read_body(conn) {:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body) payload = if body == "", do: %{}, else: Jason.decode!(body)

View File

@@ -1,6 +1,8 @@
defmodule BDS.Desktop.ShellController do defmodule BDS.Desktop.ShellController do
@moduledoc false @moduledoc false
alias BDS.UI.Sidebar
def index_html do def index_html do
BDS.UI.ShellPage.render() BDS.UI.ShellPage.render()
end end
@@ -48,6 +50,28 @@ defmodule BDS.Desktop.ShellController do
end end
end end
def sidebar_json(payload) when is_map(payload) do
view = Map.get(payload, "view") || Map.get(payload, :view)
filters = Map.get(payload, "filters") || Map.get(payload, :filters) || %{}
data =
try do
case active_project_id() do
nil -> Sidebar.view(nil, view, filters)
project_id -> Sidebar.view(project_id, view, filters)
end
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
Sidebar.view(nil, view, filters)
end
Jason.encode!(%{status: "ok", view: view, data: data})
end
defp normalize_error(error) when is_map(error), do: error defp normalize_error(error) when is_map(error), do: error
defp normalize_error(error), do: %{message: inspect(error)} defp normalize_error(error), do: %{message: inspect(error)}
@@ -149,6 +173,17 @@ defmodule BDS.Desktop.ShellController do
} }
end end
defp active_project_id do
BDS.Projects.shell_snapshot().active_project_id
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
nil
end
defp present?(value) when is_binary(value), do: String.trim(value) != "" defp present?(value) when is_binary(value), do: String.trim(value) != ""
defp present?(_value), do: false defp present?(_value), do: false

View File

@@ -13,38 +13,52 @@ defmodule BDS.UI.Sidebar do
alias BDS.Templates.Template alias BDS.Templates.Template
@page_category "page" @page_category "page"
@default_page_size 500
def snapshot(nil), do: empty_snapshot() def snapshot(nil), do: empty_snapshot()
def snapshot(project_id) when is_binary(project_id) do 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), "posts" => view(project_id, "posts"),
"pages" => posts_view(posts, translation_counts, true), "pages" => view(project_id, "pages"),
"media" => media_view(media_items), "media" => view(project_id, "media"),
"scripts" => entity_list_view("Scripts", "Automation helpers", "scripts", scripts), "scripts" => view(project_id, "scripts"),
"templates" => "templates" => view(project_id, "templates"),
entity_list_view("Templates", "Site rendering", "templates", templates), "tags" => view(project_id, "tags"),
"tags" => tags_nav_view(tags), "chat" => view(project_id, "chat"),
"chat" => entity_list_view("Chat", "AI conversations", "chat", conversations),
"import" => entity_list_view("Import", "Import definitions", "import", []), "import" => entity_list_view("Import", "Import definitions", "import", []),
"git" => git_view(), "git" => git_view(),
"settings" => settings_nav_view() "settings" => settings_nav_view()
} }
end 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 def empty_snapshot do
%{ %{
"posts" => posts_view([], %{}, false), "posts" => empty_view("posts"),
"pages" => posts_view([], %{}, true), "pages" => empty_view("pages"),
"media" => media_view([]), "media" => empty_view("media"),
"scripts" => entity_list_view("Scripts", "Automation helpers", "scripts", []), "scripts" => entity_list_view("Scripts", "Automation helpers", "scripts", []),
"templates" => entity_list_view("Templates", "Site rendering", "templates", []), "templates" => entity_list_view("Templates", "Site rendering", "templates", []),
"tags" => tags_nav_view([]), "tags" => tags_nav_view([]),
@@ -55,15 +69,67 @@ defmodule BDS.UI.Sidebar do
} }
end end
defp posts_view(posts, translation_counts, pages?) do defp empty_view("posts"), do: posts_view_data([], [], %{}, false, empty_filter_params())
filtered_posts = Enum.filter(posts, &(page_post?(&1) == pages?)) defp empty_view("pages"), do: posts_view_data([], [], %{}, true, empty_filter_params())
grouped_posts = group_posts(filtered_posts) 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"), title: if(pages?, do: "Pages", else: "Posts"),
subtitle: if(pages?, do: "Standalone pages", else: "Drafts, published entries, and archive history"), subtitle: if(pages?, do: "Standalone pages", else: "Drafts, published entries, and archive history"),
layout: "post_list", 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: [ sections: [
build_post_section("Drafts", :draft, grouped_posts.draft, translation_counts, false), build_post_section("Drafts", :draft, grouped_posts.draft, translation_counts, false),
build_post_section("Published", :published, grouped_posts.published, translation_counts, true), build_post_section("Published", :published, grouped_posts.published, translation_counts, true),
@@ -72,20 +138,61 @@ defmodule BDS.UI.Sidebar do
} }
end 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", title: "Media",
subtitle: "Images and files", subtitle: "Images and files",
layout: "media_grid", 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: items:
Enum.map(media_items, fn media -> Enum.map(limited_media, fn media ->
%{ %{
id: media.id, id: media.id,
title: display_media_title(media), title: display_media_title(media),
meta: media_size_label(media.size), meta: media_size_label(media.size),
mime_type: media.mime_type, mime_type: media.mime_type,
route: "media" route: "media",
updated_at: media.updated_at,
tags: media.tags || [],
search_blob: media_search_blob(media)
} }
end) end)
} }
@@ -167,9 +274,12 @@ defmodule BDS.UI.Sidebar do
id: post.id, id: post.id,
title: display_post_title(post), title: display_post_title(post),
categories: post.categories || [], categories: post.categories || [],
tags: post.tags || [],
status: Atom.to_string(post.status),
language_count: 1 + Map.get(translation_counts, post.id, 0), 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), 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) end)
} }
@@ -184,10 +294,13 @@ defmodule BDS.UI.Sidebar do
id: post.id, id: post.id,
title: post.title, title: post.title,
slug: post.slug, slug: post.slug,
excerpt: post.excerpt,
status: post.status, status: post.status,
tags: post.tags,
categories: post.categories, categories: post.categories,
updated_at: post.updated_at, updated_at: post.updated_at,
published_at: post.published_at published_at: post.published_at,
language: post.language
} }
) )
end end
@@ -212,7 +325,11 @@ defmodule BDS.UI.Sidebar do
title: media.title, title: media.title,
original_name: media.original_name, original_name: media.original_name,
mime_type: media.mime_type, 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 end
@@ -267,6 +384,162 @@ defmodule BDS.UI.Sidebar do
Enum.any?(post.categories || [], &(String.downcase(to_string(&1)) == @page_category)) Enum.any?(post.categories || [], &(String.downcase(to_string(&1)) == @page_category))
end 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 defp display_post_title(post) do
cond do cond do
present?(post.title) -> post.title present?(post.title) -> post.title

View File

@@ -45,6 +45,25 @@
"render.video.vimeoTitle": "Vimeo-Video", "render.video.vimeoTitle": "Vimeo-Video",
"render.video.youtubeTitle": "YouTube-Video", "render.video.youtubeTitle": "YouTube-Video",
"sidebar.chat.yesterday": "Gestern", "sidebar.chat.yesterday": "Gestern",
"sidebar.tags": "Schlagwörter",
"sidebar.categories": "Kategorien",
"sidebar.clearTags": "Tags löschen",
"sidebar.clearCategories": "Kategorien löschen",
"sidebar.noPostsYet": "Noch keine Beiträge",
"sidebar.noPagesYet": "Noch keine Seiten",
"sidebar.noMediaYet": "Noch keine Medien",
"sidebar.search": "Suchen",
"sidebar.searchPostsPlaceholder": "Beiträge durchsuchen...",
"sidebar.searchPagesPlaceholder": "Seiten durchsuchen...",
"sidebar.searchMediaPlaceholder": "Medien durchsuchen...",
"sidebar.toggleFilters": "Filter umschalten",
"sidebar.results": "%{count} Ergebnisse",
"sidebar.resultsFor": "%{count} Ergebnisse für \"%{query}\"",
"sidebar.clearFilters": "Filter löschen",
"sidebar.noMatchingPosts": "Keine passenden Beiträge",
"sidebar.loadMore": "Mehr laden (%{loaded} von %{total})",
"sidebar.loading": "Lädt...",
"sidebar.noMediaFiles": "Keine Mediendateien",
"%{count} media": "%{count} Medien", "%{count} media": "%{count} Medien",
"%{count} posts": "%{count} Beiträge", "%{count} posts": "%{count} Beiträge",
"2 langs": "2 Sprachen", "2 langs": "2 Sprachen",

View File

@@ -45,6 +45,25 @@
"render.video.vimeoTitle": "Vimeo video", "render.video.vimeoTitle": "Vimeo video",
"render.video.youtubeTitle": "YouTube video", "render.video.youtubeTitle": "YouTube video",
"sidebar.chat.yesterday": "Yesterday", "sidebar.chat.yesterday": "Yesterday",
"sidebar.tags": "Tags",
"sidebar.categories": "Categories",
"sidebar.clearTags": "Clear tags",
"sidebar.clearCategories": "Clear categories",
"sidebar.noPostsYet": "No posts yet",
"sidebar.noPagesYet": "No pages yet",
"sidebar.noMediaYet": "No media yet",
"sidebar.search": "Search",
"sidebar.searchPostsPlaceholder": "Search posts...",
"sidebar.searchPagesPlaceholder": "Search pages...",
"sidebar.searchMediaPlaceholder": "Search media...",
"sidebar.toggleFilters": "Toggle Filters",
"sidebar.results": "%{count} results",
"sidebar.resultsFor": "%{count} results for \"%{query}\"",
"sidebar.clearFilters": "Clear filters",
"sidebar.noMatchingPosts": "No matching posts",
"sidebar.loadMore": "Load more (%{loaded} of %{total})",
"sidebar.loading": "Loading...",
"sidebar.noMediaFiles": "No media files",
"%{count} media": "%{count} media", "%{count} media": "%{count} media",
"%{count} posts": "%{count} posts", "%{count} posts": "%{count} posts",
"2 langs": "2 langs", "2 langs": "2 langs",

View File

@@ -45,6 +45,25 @@
"render.video.vimeoTitle": "Vídeo de Vimeo", "render.video.vimeoTitle": "Vídeo de Vimeo",
"render.video.youtubeTitle": "Vídeo de YouTube", "render.video.youtubeTitle": "Vídeo de YouTube",
"sidebar.chat.yesterday": "Ayer", "sidebar.chat.yesterday": "Ayer",
"sidebar.tags": "Etiquetas",
"sidebar.categories": "Categorías",
"sidebar.clearTags": "Limpiar etiquetas",
"sidebar.clearCategories": "Limpiar categorías",
"sidebar.noPostsYet": "Aún no hay entradas",
"sidebar.noPagesYet": "Aún no hay páginas",
"sidebar.noMediaYet": "Aún no hay medios",
"sidebar.search": "Buscar",
"sidebar.searchPostsPlaceholder": "Buscar entradas...",
"sidebar.searchPagesPlaceholder": "Buscar páginas...",
"sidebar.searchMediaPlaceholder": "Buscar medios...",
"sidebar.toggleFilters": "Alternar filtros",
"sidebar.results": "%{count} resultados",
"sidebar.resultsFor": "%{count} resultados para \"%{query}\"",
"sidebar.clearFilters": "Limpiar filtros",
"sidebar.noMatchingPosts": "No hay entradas coincidentes",
"sidebar.loadMore": "Cargar más (%{loaded} de %{total})",
"sidebar.loading": "Cargando...",
"sidebar.noMediaFiles": "No hay archivos multimedia",
"%{count} media": "%{count} medios", "%{count} media": "%{count} medios",
"%{count} posts": "%{count} publicaciones", "%{count} posts": "%{count} publicaciones",
"2 langs": "2 idiomas", "2 langs": "2 idiomas",

View File

@@ -45,6 +45,25 @@
"render.video.vimeoTitle": "Vidéo Vimeo", "render.video.vimeoTitle": "Vidéo Vimeo",
"render.video.youtubeTitle": "Vidéo YouTube", "render.video.youtubeTitle": "Vidéo YouTube",
"sidebar.chat.yesterday": "Hier", "sidebar.chat.yesterday": "Hier",
"sidebar.tags": "Étiquettes",
"sidebar.categories": "Catégories",
"sidebar.clearTags": "Effacer les étiquettes",
"sidebar.clearCategories": "Effacer les catégories",
"sidebar.noPostsYet": "Aucun article pour le moment",
"sidebar.noPagesYet": "Aucune page pour le moment",
"sidebar.noMediaYet": "Aucun média pour le moment",
"sidebar.search": "Rechercher",
"sidebar.searchPostsPlaceholder": "Rechercher des articles...",
"sidebar.searchPagesPlaceholder": "Rechercher des pages...",
"sidebar.searchMediaPlaceholder": "Rechercher des médias...",
"sidebar.toggleFilters": "Afficher/masquer les filtres",
"sidebar.results": "%{count} résultats",
"sidebar.resultsFor": "%{count} résultats pour \"%{query}\"",
"sidebar.clearFilters": "Effacer les filtres",
"sidebar.noMatchingPosts": "Aucun article correspondant",
"sidebar.loadMore": "Charger plus (%{loaded} sur %{total})",
"sidebar.loading": "Chargement...",
"sidebar.noMediaFiles": "Aucun fichier média",
"%{count} media": "%{count} médias", "%{count} media": "%{count} médias",
"%{count} posts": "%{count} articles", "%{count} posts": "%{count} articles",
"2 langs": "2 langues", "2 langs": "2 langues",

View File

@@ -45,6 +45,25 @@
"render.video.vimeoTitle": "Video Vimeo", "render.video.vimeoTitle": "Video Vimeo",
"render.video.youtubeTitle": "Video YouTube", "render.video.youtubeTitle": "Video YouTube",
"sidebar.chat.yesterday": "Ieri", "sidebar.chat.yesterday": "Ieri",
"sidebar.tags": "Tag",
"sidebar.categories": "Categorie",
"sidebar.clearTags": "Cancella tag",
"sidebar.clearCategories": "Cancella categorie",
"sidebar.noPostsYet": "Nessun post",
"sidebar.noPagesYet": "Nessuna pagina",
"sidebar.noMediaYet": "Nessun media",
"sidebar.search": "Cerca",
"sidebar.searchPostsPlaceholder": "Cerca post...",
"sidebar.searchPagesPlaceholder": "Cerca pagine...",
"sidebar.searchMediaPlaceholder": "Cerca media...",
"sidebar.toggleFilters": "Mostra/nascondi filtri",
"sidebar.results": "%{count} risultati",
"sidebar.resultsFor": "%{count} risultati per \"%{query}\"",
"sidebar.clearFilters": "Cancella filtri",
"sidebar.noMatchingPosts": "Nessun post corrispondente",
"sidebar.loadMore": "Carica altro (%{loaded} di %{total})",
"sidebar.loading": "Caricamento...",
"sidebar.noMediaFiles": "Nessun file multimediale",
"%{count} media": "%{count} media", "%{count} media": "%{count} media",
"%{count} posts": "%{count} post", "%{count} posts": "%{count} post",
"2 langs": "2 lingue", "2 langs": "2 lingue",

View File

@@ -1563,6 +1563,201 @@ button {
font-size: 28px; font-size: 28px;
} }
.sidebar-actions {
display: flex;
align-items: center;
gap: 6px;
}
.sidebar-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--vscode-descriptionForeground);
cursor: pointer;
}
.sidebar-action:hover,
.sidebar-action.active {
background: var(--vscode-toolbar-hoverBackground);
color: var(--vscode-foreground);
}
.search-box {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 6px;
padding: 10px 12px 0;
}
.search-box input {
min-width: 0;
border: 1px solid var(--vscode-input-border);
border-radius: 6px;
background: var(--vscode-input-background);
color: var(--vscode-foreground);
padding: 7px 10px;
}
.search-box button,
.clear-filter,
.filter-status button,
.load-more-button,
.calendar-year-header,
.calendar-month,
.filter-header,
.filter-chip {
border: none;
cursor: pointer;
}
.search-box button,
.clear-search {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
padding: 0 8px;
border-radius: 6px;
background: var(--vscode-input-background);
color: var(--vscode-foreground);
}
.search-box button:hover,
.clear-search:hover,
.clear-filter:hover,
.load-more-button:hover,
.calendar-year-header:hover,
.calendar-month:hover,
.filter-header:hover,
.filter-chip:hover {
background: var(--vscode-list-hoverBackground);
}
.calendar-view,
.filter-panel,
.filter-status,
.sidebar-load-more {
padding: 10px 12px 0;
}
.collapsible-header {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
background: transparent;
color: var(--vscode-foreground);
text-align: left;
}
.collapse-icon {
width: 12px;
color: var(--vscode-descriptionForeground);
}
.calendar-years,
.calendar-months,
.filter-chips {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 6px;
}
.calendar-year-header,
.calendar-month {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
background: transparent;
color: var(--vscode-foreground);
text-align: left;
}
.calendar-year-header.selected,
.calendar-month.selected,
.filter-chip.active {
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}
.year-count,
.month-count,
.sidebar-section-count {
margin-left: auto;
color: var(--vscode-descriptionForeground);
font-size: 11px;
}
.month-label,
.year-label {
flex: 1;
}
.calendar-months {
padding-left: 18px;
}
.filter-section {
padding-top: 4px;
}
.filter-header {
width: 100%;
padding: 6px 0;
background: transparent;
color: var(--vscode-foreground);
text-align: left;
}
.filter-chips {
flex-direction: row;
flex-wrap: wrap;
}
.filter-chip {
padding: 5px 10px;
border-radius: 999px;
background: var(--vscode-input-background);
color: var(--vscode-foreground);
}
.filter-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.filter-status button,
.load-more-button {
padding: 6px 10px;
border-radius: 6px;
background: var(--vscode-input-background);
color: var(--vscode-foreground);
}
.sidebar-load-more {
padding-bottom: 12px;
}
.load-more-button {
width: 100%;
}
.media-item-info { .media-item-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -14,6 +14,8 @@ const state = {
session: hydrateSession(clone(bootstrap.session)), session: hydrateSession(clone(bootstrap.session)),
status: clone(bootstrap.status), status: clone(bootstrap.status),
projects: normalizeProjects(bootstrap.projects), projects: normalizeProjects(bootstrap.projects),
sidebarContent: clone(bootstrap.content.sidebar),
sidebarFilters: hydrateSidebarFilters(bootstrap.content.sidebar),
projectMenuOpen: false, projectMenuOpen: false,
taskStatus: normalizeTaskStatus(bootstrap.task_status), taskStatus: normalizeTaskStatus(bootstrap.task_status),
handledTaskResults: {}, handledTaskResults: {},
@@ -135,6 +137,7 @@ function renderActivityButton(view) {
function renderSidebar() { function renderSidebar() {
const view = currentSidebarView(); const view = currentSidebarView();
const data = currentSidebarData(); const data = currentSidebarData();
const filterState = currentSidebarFilterState(view.id);
root.querySelector(".sidebar").innerHTML = ` root.querySelector(".sidebar").innerHTML = `
<div class="sidebar-header"> <div class="sidebar-header">
@@ -142,10 +145,187 @@ function renderSidebar() {
<strong>${escapeHtml(tText(data.title))}</strong> <strong>${escapeHtml(tText(data.title))}</strong>
<span class="sidebar-subtitle">${escapeHtml(tText(data.subtitle))}</span> <span class="sidebar-subtitle">${escapeHtml(tText(data.subtitle))}</span>
</div> </div>
${data.filters?.enabled ? `
<div class="sidebar-actions">
<button class="sidebar-action ${filterState.showFilters ? "active" : ""}" data-sidebar-toggle-filters="${escapeHtmlAttribute(view.id)}" type="button" title="${escapeHtmlAttribute(t(data.filters.toggle_filters_label))}">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 12v-1h4v1H6zM4 8v-1h8v1H4zm-2-4v-1h12v1H2z"></path>
</svg>
</button>
</div> </div>
<div class="sidebar-content"> ` : ""}
</div>
${renderSidebarSearchBox(data, view, filterState)}
${renderSidebarFilterPanel(data, view, filterState)}
${renderSidebarFilterStatus(data, view, filterState)}
<div class="sidebar-content sidebar-body">
${renderSidebarBody(data, view)} ${renderSidebarBody(data, view)}
</div> </div>
${renderSidebarLoadMore(data, view)}
`;
}
function renderSidebarSearchBox(data, view, filterState) {
if (!data.filters?.enabled) {
return "";
}
return `
<form class="search-box" data-sidebar-search="${escapeHtmlAttribute(view.id)}">
<input
type="text"
value="${escapeHtmlAttribute(filterState.search || "") }"
placeholder="${escapeHtmlAttribute(t(data.filters.search_placeholder))}"
data-sidebar-search-input="${escapeHtmlAttribute(view.id)}"
>
<button type="submit" title="${escapeHtmlAttribute(t("sidebar.search"))}">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M15.7 14.3l-4.2-4.2c-.2-.2-.5-.3-.8-.3.9-1.1 1.5-2.5 1.5-4C12.2 2.6 9.6 0 6.4 0S.6 2.6.6 5.8s2.6 5.8 5.8 5.8c1.5 0 2.9-.5 4-1.4 0 .3.1.6.3.8l4.2 4.2c.2.2.5.3.7.3s.5-.1.7-.3c.4-.4.4-1 0-1.4zm-9.3-4c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5z"></path>
</svg>
</button>
${filterState.search ? `<button type="button" class="clear-search" data-sidebar-clear-search="${escapeHtmlAttribute(view.id)}" title="${escapeHtmlAttribute(t("sidebar.clearFilters"))}">✕</button>` : ""}
</form>
`;
}
function renderSidebarFilterPanel(data, view, filterState) {
if (!data.filters?.enabled || !filterState.showFilters) {
return "";
}
return `
${renderSidebarArchiveFilter(data, view, filterState)}
${renderSidebarFilterChips(data, view, filterState)}
`;
}
function renderSidebarArchiveFilter(data, view, filterState) {
const entries = Array.isArray(data.filters?.year_month_counts) ? data.filters.year_month_counts : [];
const years = groupSidebarYearMonths(entries);
return `
<div class="calendar-view">
<button class="calendar-header collapsible-header ${filterState.archiveCollapsed ? "collapsed" : "expanded"}" data-sidebar-toggle-collapse="${escapeHtmlAttribute(view.id)}:archive" type="button">
<span class="collapse-icon">${filterState.archiveCollapsed ? "▶" : "▼"}</span>
<span>${escapeHtml(t(data.filters.archive_label))}</span>
</button>
${(filterState.year || filterState.month) ? `<button class="clear-filter" data-sidebar-clear-date="${escapeHtmlAttribute(view.id)}" type="button" title="${escapeHtmlAttribute(t(data.filters.clear_filters_label))}">✕</button>` : ""}
${filterState.archiveCollapsed ? "" : `
<div class="calendar-years">
${years
.map(
(yearEntry) => `
<div class="calendar-year">
<button class="calendar-year-header ${filterState.year === yearEntry.year && !filterState.month ? "selected" : ""}" data-sidebar-year="${escapeHtmlAttribute(view.id)}:${yearEntry.year}" type="button">
<span class="expand-icon">${filterState.expandedYear === yearEntry.year ? "▼" : "▶"}</span>
<span class="year-label">${escapeHtml(String(yearEntry.year))}</span>
<span class="year-count">${escapeHtml(String(yearEntry.count))}</span>
</button>
${filterState.expandedYear === yearEntry.year ? `
<div class="calendar-months">
${yearEntry.months
.map(
(monthEntry) => `
<button class="calendar-month ${filterState.year === yearEntry.year && filterState.month === monthEntry.month ? "selected" : ""}" data-sidebar-month="${escapeHtmlAttribute(view.id)}:${yearEntry.year}:${monthEntry.month}" type="button">
<span class="month-label">${escapeHtml(formatDashboardMonth(yearEntry.year, monthEntry.month))}</span>
<span class="month-count">${escapeHtml(String(monthEntry.count))}</span>
</button>
`
)
.join("")}
</div>
` : ""}
</div>
`
)
.join("")}
</div>
`}
</div>
`;
}
function renderSidebarFilterChips(data, view, filterState) {
const tags = Array.isArray(data.filters?.available_tags) ? data.filters.available_tags : [];
const categories = Array.isArray(data.filters?.available_categories) ? data.filters.available_categories : [];
return `
<div class="filter-panel">
${tags.length ? `
<div class="filter-section">
<button class="filter-header collapsible-header ${filterState.tagsCollapsed ? "collapsed" : "expanded"}" data-sidebar-toggle-collapse="${escapeHtmlAttribute(view.id)}:tags" type="button">
<span class="collapse-icon">${filterState.tagsCollapsed ? "▶" : "▼"}</span>
<span>${escapeHtml(t(data.filters.tags_label))}</span>
</button>
${filterState.tagsCollapsed ? "" : `
<div class="filter-chips">
${tags
.map(
(tag) => `
<button class="filter-chip ${filterState.tags.includes(tag) ? "active" : ""}" data-sidebar-tag="${escapeHtmlAttribute(view.id)}:${escapeHtmlAttribute(tag)}" type="button">
${escapeHtml(tag)}
</button>
`
)
.join("")}
</div>
`}
</div>
` : ""}
${categories.length ? `
<div class="filter-section">
<button class="filter-header collapsible-header ${filterState.categoriesCollapsed ? "collapsed" : "expanded"}" data-sidebar-toggle-collapse="${escapeHtmlAttribute(view.id)}:categories" type="button">
<span class="collapse-icon">${filterState.categoriesCollapsed ? "▶" : "▼"}</span>
<span>${escapeHtml(t(data.filters.categories_label))}</span>
</button>
${filterState.categoriesCollapsed ? "" : `
<div class="filter-chips">
${categories
.map(
(category) => `
<button class="filter-chip ${filterState.categories.includes(category) ? "active" : ""}" data-sidebar-category="${escapeHtmlAttribute(view.id)}:${escapeHtmlAttribute(category)}" type="button">
${escapeHtml(category)}
</button>
`
)
.join("")}
</div>
`}
</div>
` : ""}
</div>
`;
}
function renderSidebarFilterStatus(data, view, filterState) {
if (!data.filters?.enabled || !data.filters.has_active_filters) {
return "";
}
const count = Number(data.filters.total_count) || 0;
const label = filterState.search
? t(data.filters.results_for_label, { count, query: filterState.search })
: t(data.filters.results_label, { count });
return `
<div class="filter-status">
<span>${escapeHtml(label)}</span>
<button data-sidebar-clear-filters="${escapeHtmlAttribute(view.id)}" type="button">${escapeHtml(t(data.filters.clear_filters_label))}</button>
</div>
`;
}
function renderSidebarLoadMore(data, view) {
if (!data.filters?.enabled || !data.filters.has_more) {
return "";
}
return `
<div class="sidebar-load-more">
<button class="load-more-button" data-sidebar-load-more="${escapeHtmlAttribute(view.id)}" type="button">
${escapeHtml(t("sidebar.loadMore", { loaded: data.filters.loaded_count || 0, total: data.filters.total_count || 0 }))}
</button>
</div>
`; `;
} }
@@ -340,6 +520,113 @@ function renderSidebarEmpty(message) {
`; `;
} }
function groupSidebarYearMonths(entries) {
const years = new Map();
entries.forEach((entry) => {
const year = Number(entry.year);
const month = Number(entry.month);
const count = Number(entry.count) || 0;
if (!years.has(year)) {
years.set(year, { year, count: 0, months: [] });
}
const yearEntry = years.get(year);
yearEntry.count += count;
yearEntry.months.push({ month, count });
});
return Array.from(years.values())
.map((yearEntry) => ({
...yearEntry,
months: yearEntry.months.sort((left, right) => right.month - left.month),
}))
.sort((left, right) => right.year - left.year);
}
function hydrateSidebarFilters(sidebarContent) {
return Object.fromEntries(
Object.entries(sidebarContent || {}).map(([viewId, data]) => [viewId, defaultSidebarFilterState(viewId, data)])
);
}
function defaultSidebarFilterState(viewId, data) {
const selected = data?.filters?.selected || {};
return {
search: selected.search || "",
year: selected.year || null,
month: selected.month || null,
tags: Array.isArray(selected.tags) ? [...selected.tags] : [],
categories: Array.isArray(selected.categories) ? [...selected.categories] : [],
showFilters: false,
archiveCollapsed: true,
tagsCollapsed: true,
categoriesCollapsed: true,
expandedYear: selected.year || null,
displayLimit: data?.filters?.display_limit || data?.filters?.max_items || 500,
};
}
function currentSidebarFilterState(viewId) {
if (!state.sidebarFilters[viewId]) {
state.sidebarFilters[viewId] = defaultSidebarFilterState(viewId, state.sidebarContent[viewId]);
}
return state.sidebarFilters[viewId];
}
function applySidebarPostFilters(viewId) {
void refreshSidebarView(viewId);
}
function applySidebarMediaFilters(viewId) {
void refreshSidebarView(viewId);
}
async function refreshSidebarView(viewId) {
try {
const filterState = currentSidebarFilterState(viewId);
const response = await fetch("/api/sidebar", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
view: viewId,
filters: {
search: filterState.search,
year: filterState.year,
month: filterState.month,
tags: filterState.tags,
categories: filterState.categories,
display_limit: filterState.displayLimit,
},
}),
});
if (!response.ok) {
return;
}
const payload = await response.json();
if (payload.status !== "ok") {
return;
}
state.sidebarContent[viewId] = payload.data;
state.sidebarFilters[viewId] = {
...filterState,
displayLimit: payload.data?.filters?.display_limit || filterState.displayLimit,
};
render();
} catch (_error) {
// Keep the shell usable if sidebar filtering is temporarily unavailable.
}
}
function renderTabs() { function renderTabs() {
const tabs = state.session.tabs; const tabs = state.session.tabs;
const node = root.querySelector(".tab-bar"); const node = root.querySelector(".tab-bar");
@@ -794,6 +1081,171 @@ function bindEvents() {
}; };
}); });
root.querySelectorAll("[data-sidebar-toggle-filters]").forEach((button) => {
button.onclick = () => {
const viewId = button.dataset.sidebarToggleFilters;
const filterState = currentSidebarFilterState(viewId);
filterState.showFilters = !filterState.showFilters;
render();
};
});
root.querySelectorAll("form[data-sidebar-search]").forEach((form) => {
form.onsubmit = (event) => {
event.preventDefault();
const viewId = form.dataset.sidebarSearch;
const input = form.querySelector("input[data-sidebar-search-input]");
const filterState = currentSidebarFilterState(viewId);
filterState.search = input?.value?.trim() || "";
filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
if (viewId === "media") {
applySidebarMediaFilters(viewId);
} else {
applySidebarPostFilters(viewId);
}
};
});
root.querySelectorAll("[data-sidebar-clear-search]").forEach((button) => {
button.onclick = () => {
const viewId = button.dataset.sidebarClearSearch;
const filterState = currentSidebarFilterState(viewId);
filterState.search = "";
filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
if (viewId === "media") {
applySidebarMediaFilters(viewId);
} else {
applySidebarPostFilters(viewId);
}
};
});
root.querySelectorAll("[data-sidebar-toggle-collapse]").forEach((button) => {
button.onclick = () => {
const [viewId, section] = button.dataset.sidebarToggleCollapse.split(":");
const filterState = currentSidebarFilterState(viewId);
if (section === "archive") {
filterState.archiveCollapsed = !filterState.archiveCollapsed;
}
if (section === "tags") {
filterState.tagsCollapsed = !filterState.tagsCollapsed;
}
if (section === "categories") {
filterState.categoriesCollapsed = !filterState.categoriesCollapsed;
}
render();
};
});
root.querySelectorAll("[data-sidebar-year]").forEach((button) => {
button.onclick = () => {
const [viewId, year] = button.dataset.sidebarYear.split(":");
const filterState = currentSidebarFilterState(viewId);
const nextYear = Number.parseInt(year, 10);
filterState.expandedYear = filterState.expandedYear === nextYear ? null : nextYear;
filterState.year = nextYear;
filterState.month = null;
filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
if (viewId === "media") {
applySidebarMediaFilters(viewId);
} else {
applySidebarPostFilters(viewId);
}
};
});
root.querySelectorAll("[data-sidebar-month]").forEach((button) => {
button.onclick = () => {
const [viewId, year, month] = button.dataset.sidebarMonth.split(":");
const filterState = currentSidebarFilterState(viewId);
filterState.year = Number.parseInt(year, 10);
filterState.month = Number.parseInt(month, 10);
filterState.expandedYear = filterState.year;
filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
if (viewId === "media") {
applySidebarMediaFilters(viewId);
} else {
applySidebarPostFilters(viewId);
}
};
});
root.querySelectorAll("[data-sidebar-clear-date]").forEach((button) => {
button.onclick = () => {
const viewId = button.dataset.sidebarClearDate;
const filterState = currentSidebarFilterState(viewId);
filterState.year = null;
filterState.month = null;
filterState.expandedYear = null;
filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
if (viewId === "media") {
applySidebarMediaFilters(viewId);
} else {
applySidebarPostFilters(viewId);
}
};
});
root.querySelectorAll("[data-sidebar-tag]").forEach((button) => {
button.onclick = () => {
const [viewId, tag] = button.dataset.sidebarTag.split(":");
const filterState = currentSidebarFilterState(viewId);
filterState.tags = toggleSidebarFilterValue(filterState.tags, tag);
filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
if (viewId === "media") {
applySidebarMediaFilters(viewId);
} else {
applySidebarPostFilters(viewId);
}
};
});
root.querySelectorAll("[data-sidebar-category]").forEach((button) => {
button.onclick = () => {
const [viewId, category] = button.dataset.sidebarCategory.split(":");
const filterState = currentSidebarFilterState(viewId);
filterState.categories = toggleSidebarFilterValue(filterState.categories, category);
filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
applySidebarPostFilters(viewId);
};
});
root.querySelectorAll("[data-sidebar-clear-filters]").forEach((button) => {
button.onclick = () => {
const viewId = button.dataset.sidebarClearFilters;
const existing = currentSidebarFilterState(viewId);
state.sidebarFilters[viewId] = {
...defaultSidebarFilterState(viewId, state.sidebarContent[viewId]),
showFilters: existing.showFilters,
archiveCollapsed: existing.archiveCollapsed,
tagsCollapsed: existing.tagsCollapsed,
categoriesCollapsed: existing.categoriesCollapsed,
};
if (viewId === "media") {
applySidebarMediaFilters(viewId);
} else {
applySidebarPostFilters(viewId);
}
};
});
root.querySelectorAll("[data-sidebar-load-more]").forEach((button) => {
button.onclick = () => {
const viewId = button.dataset.sidebarLoadMore;
const filterState = currentSidebarFilterState(viewId);
filterState.displayLimit += state.sidebarContent[viewId]?.filters?.max_items || 500;
if (viewId === "media") {
applySidebarMediaFilters(viewId);
} else {
applySidebarPostFilters(viewId);
}
};
});
root.querySelectorAll("[data-open-tab]").forEach((button) => { root.querySelectorAll("[data-open-tab]").forEach((button) => {
button.onclick = () => { button.onclick = () => {
openTab(button.dataset.openRoute, button.dataset.openTab, button.dataset.openTitle, true); openTab(button.dataset.openRoute, button.dataset.openTab, button.dataset.openTitle, true);
@@ -1383,7 +1835,7 @@ function activeItem() {
return null; return null;
} }
const items = Object.values(bootstrap.content.sidebar).flatMap(flattenSidebarItems); const items = Object.values(state.sidebarContent).flatMap(flattenSidebarItems);
return items.find((item) => item.route === tab.type && tabIdForItem(item, item.route) === tab.id) || null; return items.find((item) => item.route === tab.type && tabIdForItem(item, item.route) === tab.id) || null;
} }
@@ -1414,7 +1866,7 @@ function currentSidebarView() {
} }
function currentSidebarData() { function currentSidebarData() {
return bootstrap.content.sidebar[state.session.active_view] || bootstrap.content.sidebar[bootstrap.registry.default_sidebar_view]; return state.sidebarContent[state.session.active_view] || state.sidebarContent[bootstrap.registry.default_sidebar_view];
} }
function currentTabRef() { function currentTabRef() {
@@ -1615,6 +2067,12 @@ function tabIdForItem(item, route) {
return item.id; return item.id;
} }
function toggleSidebarFilterValue(values, value) {
return values.includes(value)
? values.filter((entry) => entry !== value)
: [...values, value];
}
function sidebarViews() { function sidebarViews() {
return bootstrap.registry.sidebar_views; return bootstrap.registry.sidebar_views;
} }

View File

@@ -180,6 +180,33 @@ defmodule BDS.UI.ShellTest do
assert css =~ ".settings-nav-entry" assert css =~ ".settings-nav-entry"
end end
test "shell bootstrap and static bundle expose old sidebar filters and the post cutoff contract" do
html = ShellPage.render()
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert html =~ ~s("filters")
assert html =~ ~s("search_placeholder":"sidebar.searchPostsPlaceholder")
assert html =~ ~s("search_placeholder":"sidebar.searchMediaPlaceholder")
assert html =~ ~s("year_month_counts")
assert html =~ ~s("available_tags")
assert html =~ ~s("available_categories")
assert html =~ ~s("max_items":500)
assert js =~ "renderSidebarSearchBox"
assert js =~ "renderSidebarArchiveFilter"
assert js =~ "renderSidebarFilterPanel"
assert js =~ "renderSidebarFilterStatus"
assert js =~ "applySidebarPostFilters"
assert js =~ "applySidebarMediaFilters"
assert css =~ ".search-box"
assert css =~ ".filter-panel"
assert css =~ ".calendar-view"
assert css =~ ".filter-chip"
assert css =~ ".filter-status"
end
test "static shell bundle exists for direct browser inspection" do test "static shell bundle exists for direct browser inspection" do
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/index.html") assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/index.html")
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.css") assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.css")