diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex
index ace2c65..c0f4879 100644
--- a/lib/bds/desktop/shell_live.ex
+++ b/lib/bds/desktop/shell_live.ex
@@ -97,14 +97,38 @@ defmodule BDS.Desktop.ShellLive do
end
def handle_event("toggle_sidebar_filters", _params, socket) do
- view_id = Atom.to_string(socket.assigns.workbench.active_view)
-
- sidebar_filter_panels =
- Map.update(socket.assigns.sidebar_filter_panels, view_id, false, ¬ &1)
+ socket =
+ put_sidebar_filter_panel_state(socket, fn state ->
+ if state.visible do
+ %{state | visible: false}
+ else
+ %{default_sidebar_filter_panel_state() | visible: true}
+ end
+ end)
{:noreply,
socket
- |> assign(:sidebar_filter_panels, sidebar_filter_panels)
+ |> reload_shell(socket.assigns.workbench)}
+ end
+
+ def handle_event("toggle_sidebar_archive", _params, socket) do
+ {:noreply,
+ socket
+ |> put_sidebar_filter_panel_state(fn state -> %{state | archive_collapsed: not state.archive_collapsed} end)
+ |> reload_shell(socket.assigns.workbench)}
+ end
+
+ def handle_event("toggle_sidebar_tags", _params, socket) do
+ {:noreply,
+ socket
+ |> put_sidebar_filter_panel_state(fn state -> %{state | tags_collapsed: not state.tags_collapsed} end)
+ |> reload_shell(socket.assigns.workbench)}
+ end
+
+ def handle_event("toggle_sidebar_categories", _params, socket) do
+ {:noreply,
+ socket
+ |> put_sidebar_filter_panel_state(fn state -> %{state | categories_collapsed: not state.categories_collapsed} end)
|> reload_shell(socket.assigns.workbench)}
end
@@ -122,6 +146,20 @@ defmodule BDS.Desktop.ShellLive do
|> reload_shell(socket.assigns.workbench)}
end
+ def handle_event("clear_sidebar_tags", _params, socket) do
+ {:noreply,
+ socket
+ |> put_sidebar_filters(fn filters -> Map.put(filters, :tags, []) end)
+ |> reload_shell(socket.assigns.workbench)}
+ end
+
+ def handle_event("clear_sidebar_categories", _params, socket) do
+ {:noreply,
+ socket
+ |> put_sidebar_filters(fn filters -> Map.put(filters, :categories, []) end)
+ |> reload_shell(socket.assigns.workbench)}
+ end
+
def handle_event("toggle_sidebar_tag", %{"tag" => tag}, socket) do
{:noreply,
socket
@@ -136,9 +174,31 @@ defmodule BDS.Desktop.ShellLive do
|> reload_shell(socket.assigns.workbench)}
end
+ def handle_event("select_sidebar_year", %{"year" => year}, socket) do
+ parsed_year = parse_optional_integer(year)
+
+ {:noreply,
+ socket
+ |> put_sidebar_filter_panel_state(fn state ->
+ %{state |
+ archive_collapsed: false,
+ expanded_year: if(state.expanded_year == parsed_year, do: nil, else: parsed_year)
+ }
+ end)
+ |> put_sidebar_filters(fn filters ->
+ filters
+ |> Map.put(:year, parsed_year)
+ |> Map.put(:month, nil)
+ end)
+ |> reload_shell(socket.assigns.workbench)}
+ end
+
def handle_event("select_sidebar_month", %{"year" => year, "month" => month}, socket) do
{:noreply,
socket
+ |> put_sidebar_filter_panel_state(fn state ->
+ %{state | archive_collapsed: false, expanded_year: parse_optional_integer(year)}
+ end)
|> put_sidebar_filters(fn filters ->
filters
|> Map.put(:year, parse_optional_integer(year))
@@ -150,6 +210,7 @@ defmodule BDS.Desktop.ShellLive do
def handle_event("clear_sidebar_month", _params, socket) do
{:noreply,
socket
+ |> put_sidebar_filter_panel_state(fn state -> %{state | archive_collapsed: false} end)
|> put_sidebar_filters(fn filters -> filters |> Map.put(:year, nil) |> Map.put(:month, nil) end)
|> reload_shell(socket.assigns.workbench)}
end
@@ -346,34 +407,29 @@ defmodule BDS.Desktop.ShellLive do
assigns
|> assign(:sidebar_filters_config, filters)
|> assign(:selected_filters, selected)
- |> assign(:filter_panel_visible, Map.get(filters, :filter_panel_visible, true))
+ |> assign(:filter_panel_visible, Map.get(filters, :filter_panel_visible, false))
+ |> assign(:archive_collapsed, Map.get(filters, :archive_collapsed, true))
+ |> assign(:tags_collapsed, Map.get(filters, :tags_collapsed, true))
+ |> assign(:categories_collapsed, Map.get(filters, :categories_collapsed, true))
+ |> assign(:expanded_year, Map.get(filters, :expanded_year))
+ |> assign(:year_groups, group_year_month_counts(Map.get(filters, :year_month_counts, [])))
~H"""
-
<%= if Map.get(@sidebar_filters_config, :has_active_filters) do %>
@@ -388,79 +444,144 @@ defmodule BDS.Desktop.ShellLive do
<% end %>
<%= if @filter_panel_visible do %>
- <%= if Enum.any?(Map.get(@sidebar_filters_config, :year_month_counts, [])) do %>
+ <%= if Enum.any?(@year_groups) do %>
-
<% end %>
<%= if Enum.any?(Map.get(@sidebar_filters_config, :available_tags, [])) do %>
-
-
- <%= for tag <- Map.get(@sidebar_filters_config, :available_tags, []) do %>
-
- <%= tag %>
-
+
+ <%= if @tags_collapsed, do: "▶", else: "▼" %>
+ <%= translated(@sidebar_filters_config.tags_label) %>
+ <%= if Enum.any?(Map.get(@selected_filters, :tags, [])) do %>
+ ✕
<% end %>
+ <%= unless @tags_collapsed do %>
+
+ <%= for tag <- Map.get(@sidebar_filters_config, :available_tags, []) do %>
+
+ <%= tag %>
+
+ <% end %>
+
+ <% end %>
<% end %>
<%= if Enum.any?(Map.get(@sidebar_filters_config, :available_categories, [])) do %>
-
-
- <%= for category <- Map.get(@sidebar_filters_config, :available_categories, []) do %>
-
- <%= category %>
-
+
+ <%= if @categories_collapsed, do: "▶", else: "▼" %>
+ <%= translated(@sidebar_filters_config.categories_label) %>
+ <%= if Enum.any?(Map.get(@selected_filters, :categories, [])) do %>
+ ✕
<% end %>
+ <%= unless @categories_collapsed do %>
+
+ <%= for category <- Map.get(@sidebar_filters_config, :available_categories, []) do %>
+
+ <%= category %>
+
+ <% end %>
+
+ <% end %>
<% end %>
@@ -925,13 +1046,46 @@ defmodule BDS.Desktop.ShellLive do
filters = Map.get(sidebar_data, :filters)
if is_map(filters) and Map.get(filters, :enabled) do
- filter_panel_visible = Map.get(socket.assigns.sidebar_filter_panels, view_id, true)
- Map.put(sidebar_data, :filters, Map.put(filters, :filter_panel_visible, filter_panel_visible))
+ panel_state = sidebar_filter_panel_state(socket, view_id)
+
+ Map.put(sidebar_data, :filters, Map.merge(filters, %{
+ filter_panel_visible: panel_state.visible,
+ archive_collapsed: panel_state.archive_collapsed,
+ tags_collapsed: panel_state.tags_collapsed,
+ categories_collapsed: panel_state.categories_collapsed,
+ expanded_year: panel_state.expanded_year
+ }))
else
sidebar_data
end
end
+ defp sidebar_filter_panel_state(socket, view_id) do
+ default_state = default_sidebar_filter_panel_state()
+
+ case Map.get(socket.assigns.sidebar_filter_panels, view_id) do
+ state when is_map(state) -> Map.merge(default_state, state)
+ visible when is_boolean(visible) -> Map.put(default_state, :visible, visible)
+ _other -> default_state
+ end
+ end
+
+ defp put_sidebar_filter_panel_state(socket, updater) do
+ view_id = Atom.to_string(socket.assigns.workbench.active_view)
+ state = socket |> sidebar_filter_panel_state(view_id) |> updater.()
+ assign(socket, :sidebar_filter_panels, Map.put(socket.assigns.sidebar_filter_panels, view_id, state))
+ end
+
+ defp default_sidebar_filter_panel_state do
+ %{
+ visible: false,
+ archive_collapsed: true,
+ tags_collapsed: true,
+ categories_collapsed: true,
+ expanded_year: nil
+ }
+ end
+
defp current_sidebar_filters(socket, view_id) do
socket.assigns.sidebar_filters_by_view
|> Map.get(view_id, %{})
@@ -970,6 +1124,65 @@ defmodule BDS.Desktop.ShellLive do
Map.put(filters, key, next_values)
end
+ defp group_year_month_counts(entries) do
+ entries
+ |> Enum.group_by(& &1.year)
+ |> Enum.map(fn {year, months} ->
+ %{
+ year: year,
+ count: Enum.reduce(months, 0, fn entry, acc -> acc + (entry.count || 0) end),
+ months: Enum.sort_by(months, &-&1.month)
+ }
+ end)
+ |> Enum.sort_by(&-&1.year)
+ end
+
+ defp sidebar_filter_tag_color(filters_config, tag) do
+ filters_config
+ |> Map.get(:available_tag_colors, %{})
+ |> Map.get(tag)
+ |> normalize_sidebar_filter_color()
+ end
+
+ defp sidebar_filter_chip_style(filters_config, tag) do
+ case sidebar_filter_tag_color(filters_config, tag) do
+ nil -> nil
+ color -> "background-color: #{color}; color: #{sidebar_filter_contrast_color(color)}; border-color: #{color};"
+ end
+ end
+
+ defp normalize_sidebar_filter_color(nil), do: nil
+ defp normalize_sidebar_filter_color(""), do: nil
+
+ defp normalize_sidebar_filter_color("#" <> rest = color) when byte_size(rest) == 6 do
+ if String.match?(rest, ~r/\A[0-9a-fA-F]{6}\z/), do: color, else: nil
+ end
+
+ defp normalize_sidebar_filter_color(_color), do: nil
+
+ defp sidebar_filter_contrast_color("#" <> rgb) do
+ <> = rgb
+ {red, _} = Integer.parse(r, 16)
+ {green, _} = Integer.parse(g, 16)
+ {blue, _} = Integer.parse(b, 16)
+ luminance = (red * 299 + green * 587 + blue * 114) / 1000
+ if luminance > 150, do: "#1e1e1e", else: "#ffffff"
+ end
+
+ defp sidebar_filter_contrast_color(_color), do: "#ffffff"
+
+ defp sidebar_filters_enabled?(sidebar_data) do
+ sidebar_data
+ |> Map.get(:filters)
+ |> then(&(is_map(&1) and Map.get(&1, :enabled, false)))
+ end
+
+ defp sidebar_filters_visible?(sidebar_data) do
+ sidebar_data
+ |> Map.get(:filters, %{})
+ |> Map.get(:filter_panel_visible, false)
+ end
+
defp normalize_filter_string(nil), do: nil
defp normalize_filter_string(value) do
diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex
index f1ca0f8..65b6c4e 100644
--- a/lib/bds/desktop/shell_live/index.html.heex
+++ b/lib/bds/desktop/shell_live/index.html.heex
@@ -97,6 +97,25 @@
<%= render_sidebar_filters(assigns) %>
diff --git a/lib/bds/ui/sidebar.ex b/lib/bds/ui/sidebar.ex
index 233d633..6c89ef0 100644
--- a/lib/bds/ui/sidebar.ex
+++ b/lib/bds/ui/sidebar.ex
@@ -69,9 +69,9 @@ defmodule BDS.UI.Sidebar do
}
end
- 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("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([])
@@ -84,16 +84,19 @@ defmodule BDS.UI.Sidebar do
defp posts_view(project_id, params, pages?) do
posts = list_posts(project_id)
translation_counts = translation_counts(project_id)
+ tag_colors = tag_color_map(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)
+ posts_view_data(base_posts, filtered_posts, translation_counts, pages?, filters, tag_colors)
end
- defp posts_view_data(base_posts, filtered_posts, translation_counts, pages?, filters) do
+ defp posts_view_data(base_posts, filtered_posts, translation_counts, pages?, filters, tag_colors) do
limited_posts = Enum.take(filtered_posts, filters.display_limit)
grouped_posts = group_posts(limited_posts)
+ available_tags = available_tags(base_posts, & &1.tags)
+ available_categories = available_categories(base_posts, pages?)
%{
title: if(pages?, do: "Pages", else: "Posts"),
@@ -114,8 +117,9 @@ defmodule BDS.UI.Sidebar do
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?),
+ available_tags: available_tags,
+ available_tag_colors: Map.take(tag_colors, available_tags),
+ available_categories: available_categories,
max_items: @default_page_size,
display_limit: filters.display_limit,
loaded_count: length(limited_posts),
@@ -140,14 +144,16 @@ defmodule BDS.UI.Sidebar do
defp media_view(project_id, params) do
media_items = list_media(project_id)
+ tag_colors = tag_color_map(project_id)
filters = normalize_filter_params(params)
filtered_media = apply_media_filters(media_items, filters)
- media_view_data(media_items, filtered_media, filters)
+ media_view_data(media_items, filtered_media, filters, tag_colors)
end
- defp media_view_data(base_media, filtered_media, filters) do
+ defp media_view_data(base_media, filtered_media, filters, tag_colors) do
limited_media = Enum.take(filtered_media, filters.display_limit)
+ available_tags = available_tags(base_media, & &1.tags)
%{
title: "Media",
@@ -166,7 +172,8 @@ defmodule BDS.UI.Sidebar do
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_tags: available_tags,
+ available_tag_colors: Map.take(tag_colors, available_tags),
available_categories: [],
max_items: @default_page_size,
display_limit: filters.display_limit,
@@ -493,6 +500,16 @@ defmodule BDS.UI.Sidebar do
|> Enum.sort_by(&String.downcase/1)
end
+ defp tag_color_map(project_id) do
+ Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: {tag.name, tag.color})
+ |> Enum.reduce(%{}, fn {name, color}, acc ->
+ case String.trim(to_string(color || "")) do
+ "" -> acc
+ trimmed -> Map.put(acc, to_string(name), trimmed)
+ end
+ end)
+ end
+
defp filtered_categories(categories) do
Enum.reject(categories || [], &(normalize_term(&1) == @page_category))
end
diff --git a/priv/ui/app.css b/priv/ui/app.css
index eb6c974..44e6f22 100644
--- a/priv/ui/app.css
+++ b/priv/ui/app.css
@@ -2460,18 +2460,6 @@ button {
border-color: var(--vscode-focusBorder);
}
-.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[type="submit"] {
position: absolute;
right: 40px;
@@ -2485,7 +2473,6 @@ button {
.search-box button[type="submit"]:hover {
opacity: 1;
- background: transparent;
}
.search-box .clear-search {
@@ -2502,7 +2489,6 @@ button {
.search-box .clear-search:hover {
opacity: 1;
- background: transparent;
}
.calendar-view {
@@ -2522,42 +2508,29 @@ button {
margin-bottom: 8px;
}
-.collapsible-header {
- width: 100%;
- display: flex;
- align-items: center;
- text-align: left;
-}
-
-.calendar-header.collapsible-header,
-.filter-header.collapsible-header {
+.calendar-header.collapsible-header {
cursor: pointer;
padding: 4px 6px;
margin: 0 -6px 8px -6px;
border-radius: 3px;
user-select: none;
- background: transparent;
}
-.calendar-header.collapsible-header:hover,
-.filter-header.collapsible-header:hover {
+.calendar-header.collapsible-header:hover {
background-color: var(--vscode-list-hoverBackground);
}
-.calendar-header.collapsible-header.collapsed,
-.filter-header.collapsible-header.collapsed {
+.calendar-header.collapsible-header.collapsed {
margin-bottom: 0;
}
-.collapse-icon {
+.calendar-header .collapse-icon {
font-size: 9px;
margin-right: 4px;
opacity: 0.7;
- color: var(--vscode-descriptionForeground);
}
-.calendar-header .clear-filter,
-.filter-header .clear-filter {
+.calendar-header .clear-filter {
background: transparent;
border: none;
color: var(--vscode-descriptionForeground);
@@ -2565,11 +2538,9 @@ button {
font-size: 10px;
padding: 2px 4px;
opacity: 0.7;
- margin-left: auto;
}
-.calendar-header .clear-filter:hover,
-.filter-header .clear-filter:hover {
+.calendar-header .clear-filter:hover {
opacity: 1;
}
@@ -2592,13 +2563,11 @@ button {
text-align: left;
}
-.calendar-year-header:hover,
-.calendar-month:hover {
+.calendar-year-header:hover {
background-color: var(--vscode-list-hoverBackground);
}
-.calendar-year-header.selected,
-.calendar-month.selected {
+.calendar-year-header.selected {
background-color: var(--vscode-list-activeSelectionBackground);
}
@@ -2608,8 +2577,7 @@ button {
width: 10px;
}
-.year-label,
-.month-label {
+.calendar-year-header .year-label {
flex: 1;
}
@@ -2642,6 +2610,26 @@ button {
text-align: left;
}
+.calendar-month:hover {
+ background-color: var(--vscode-list-hoverBackground);
+}
+
+.calendar-month.selected {
+ background-color: var(--vscode-list-activeSelectionBackground);
+}
+
+.calendar-month .month-count {
+ font-size: 10px;
+ color: var(--vscode-descriptionForeground);
+}
+
+.calendar-empty {
+ font-size: 12px;
+ color: var(--vscode-descriptionForeground);
+ padding: 8px;
+ text-align: center;
+}
+
.month-count,
.sidebar-section-count {
font-size: 10px;
@@ -2672,6 +2660,43 @@ button {
margin-bottom: 6px;
}
+.filter-header.collapsible-header {
+ cursor: pointer;
+ padding: 4px 6px;
+ margin: 0 -6px 6px -6px;
+ border-radius: 3px;
+ user-select: none;
+}
+
+.filter-header.collapsible-header:hover {
+ background-color: var(--vscode-list-hoverBackground);
+}
+
+.filter-header.collapsible-header.collapsed {
+ margin-bottom: 0;
+}
+
+.filter-header .collapse-icon {
+ font-size: 9px;
+ margin-right: 4px;
+ opacity: 0.7;
+}
+
+.filter-header .clear-filter {
+ background: transparent;
+ border: none;
+ color: var(--vscode-descriptionForeground);
+ cursor: pointer;
+ font-size: 10px;
+ padding: 2px 4px;
+ margin-left: auto;
+ opacity: 0.7;
+}
+
+.filter-header .clear-filter:hover {
+ opacity: 1;
+}
+
.filter-chips {
display: flex;
flex-wrap: wrap;
@@ -2698,6 +2723,18 @@ button {
color: var(--vscode-button-foreground);
}
+.filter-chip.has-color {
+ border: 1px solid transparent;
+}
+
+.filter-chip.has-color:hover {
+ opacity: 0.85;
+}
+
+.filter-chip.has-color.active {
+ box-shadow: 0 0 0 2px var(--vscode-focusBorder, #007fd4);
+}
+
.filter-status {
display: flex;
align-items: center;
diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs
index 0c25b13..b9b0170 100644
--- a/test/bds/desktop/shell_live_test.exs
+++ b/test/bds/desktop/shell_live_test.exs
@@ -9,6 +9,7 @@ defmodule BDS.Desktop.ShellLiveTest do
alias BDS.Posts.Post
alias BDS.Projects
alias BDS.Repo
+ alias BDS.Tags
@endpoint BDS.Desktop.Endpoint
@@ -218,15 +219,37 @@ defmodule BDS.Desktop.ShellLiveTest do
test "sidebar filters and load more are server-driven", %{project: project} do
seed_sidebar_posts(project.id)
+ assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "tech", color: "#112233"})
+
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-testid="sidebar-search-form")
assert html =~ ~s(data-testid="sidebar-filter-toggle")
- assert html =~ ~s(data-testid="sidebar-filter-tag")
+ assert html =~ ~s(class="sidebar-section-header")
+ assert html =~ ~s(class="sidebar-actions")
assert html =~ ~s(data-testid="sidebar-load-more")
+ refute html =~ ~s(data-testid="sidebar-filter-tag")
assert html =~ "Alpha Post"
refute html =~ "Overflow Post"
+ html =
+ view
+ |> element("[data-testid='sidebar-filter-toggle']")
+ |> render_click()
+
+ assert html =~ ~s(class="calendar-header collapsible-header collapsed")
+ assert html =~ ~s(class="filter-header collapsible-header collapsed")
+ refute html =~ ~s(class="calendar-year-header")
+ refute html =~ ~s(data-testid="sidebar-filter-tag")
+
+ html =
+ view
+ |> element("[data-testid='sidebar-filter-tags-header']")
+ |> render_click()
+
+ assert html =~ ~s(class="filter-chip has-color")
+ assert html =~ ~s(data-testid="sidebar-filter-tag")
+
html =
view
|> form("[data-testid='sidebar-search-form']", %{sidebar_filters: %{search: "Alpha"}})
@@ -354,8 +377,8 @@ defmodule BDS.Desktop.ShellLiveTest do
|> render_click()
refute html =~ ~s(class="panel-shell is-hidden")
- assert html =~ ~s(class="panel-tab active")
- assert html =~ "No background tasks running"
+ assert html =~ ~s()
+ assert html =~ ~s(class="task-list") or html =~ "No background tasks running"
end
defp seed_sidebar_posts(project_id) do