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 %>
-
+
+ <%= if @archive_collapsed, do: "▶", else: "▼" %> <%= translated(@sidebar_filters_config.archive_label) %> <%= if Map.get(@selected_filters, :year) do %> - - <% end %> -
-
- <%= for entry <- Map.get(@sidebar_filters_config, :year_month_counts, []) do %> - + <% end %>
+ <%= unless @archive_collapsed do %> +
+ <%= for year_group <- @year_groups do %> +
+
+ <%= if @expanded_year == year_group.year, do: "▼", else: "▶" %> + <%= year_group.year %> + <%= year_group.count %> +
+ <%= if @expanded_year == year_group.year do %> +
+ <%= for month_entry <- year_group.months do %> + + <% end %> +
+ <% end %> +
+ <% end %> +
+ <% end %>
<% end %>
<%= if Enum.any?(Map.get(@sidebar_filters_config, :available_tags, [])) do %>
-
<%= translated(@sidebar_filters_config.tags_label) %>
-
- <%= for tag <- Map.get(@sidebar_filters_config, :available_tags, []) do %> - +
+ <%= 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 %> + + <% end %> +
+ <% end %>
<% end %> <%= if Enum.any?(Map.get(@sidebar_filters_config, :available_categories, [])) do %>
-
<%= translated(@sidebar_filters_config.categories_label) %>
-
- <%= for category <- Map.get(@sidebar_filters_config, :available_categories, []) do %> - +
+ <%= 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 %> + + <% 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(