From fd29d17eb575922854a44f75569ebad289a10457 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 26 Apr 2026 16:17:36 +0200 Subject: [PATCH] chore: more file extractions --- lib/bds/desktop/shell_live.ex | 834 +----------------- lib/bds/desktop/shell_live/index.html.heex | 8 +- lib/bds/desktop/shell_live/post_editor.ex | 193 +++- .../desktop/shell_live/sidebar_components.ex | 514 +++++++++++ lib/bds/desktop/shell_live/sidebar_state.ex | 115 +++ test/bds/ui/shell_test.exs | 40 + 6 files changed, 902 insertions(+), 802 deletions(-) create mode 100644 lib/bds/desktop/shell_live/sidebar_components.ex create mode 100644 lib/bds/desktop/shell_live/sidebar_state.ex diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index eec1e63..2264f30 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -8,8 +8,10 @@ defmodule BDS.Desktop.ShellLive do alias BDS.Desktop.{FolderPicker, Overlay, ShellCommands, ShellData} alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents alias BDS.Desktop.ShellLive.PostEditor + alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents + alias BDS.Desktop.ShellLive.SidebarState, as: ShellSidebarState alias BDS.Desktop.MenuBar, as: DesktopMenuBar - alias BDS.{Git, Posts} + alias BDS.Git alias BDS.Media.Media alias BDS.PostLinks alias BDS.Posts.Post @@ -114,11 +116,11 @@ defmodule BDS.Desktop.ShellLive do def handle_event("toggle_sidebar_filters", _params, socket) do socket = - put_sidebar_filter_panel_state(socket, fn state -> + ShellSidebarState.put_filter_panel_state(socket, fn state -> if state.visible do %{state | visible: false} else - %{default_sidebar_filter_panel_state() | visible: true} + %{visible: true, archive_collapsed: true, tags_collapsed: true, categories_collapsed: true, expanded_year: nil} end end) @@ -130,78 +132,78 @@ defmodule BDS.Desktop.ShellLive do 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) + |> ShellSidebarState.put_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) + |> ShellSidebarState.put_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) + |> ShellSidebarState.put_filter_panel_state(fn state -> %{state | categories_collapsed: not state.categories_collapsed} end) |> reload_shell(socket.assigns.workbench)} end def handle_event("update_sidebar_search", %{"sidebar_filters" => params}, socket) do {:noreply, socket - |> put_sidebar_filters(fn filters -> Map.put(filters, :search, normalize_filter_string(Map.get(params, "search"))) end) + |> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :search, ShellSidebarState.normalize_filter_string(Map.get(params, "search"))) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("clear_sidebar_search", _params, socket) do {:noreply, socket - |> put_sidebar_filters(fn filters -> Map.put(filters, :search, nil) end) + |> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :search, nil) end) |> 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) + |> ShellSidebarState.put_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) + |> ShellSidebarState.put_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 - |> put_sidebar_filters(fn filters -> toggle_filter_value(filters, :tags, tag) end) + |> ShellSidebarState.put_filters(fn filters -> ShellSidebarState.toggle_filter_value(filters, :tags, tag) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("toggle_sidebar_category", %{"category" => category}, socket) do {:noreply, socket - |> put_sidebar_filters(fn filters -> toggle_filter_value(filters, :categories, category) end) + |> ShellSidebarState.put_filters(fn filters -> ShellSidebarState.toggle_filter_value(filters, :categories, category) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("select_sidebar_year", %{"year" => year}, socket) do - parsed_year = parse_optional_integer(year) + parsed_year = ShellSidebarState.parse_optional_integer(year) {:noreply, socket - |> put_sidebar_filter_panel_state(fn state -> + |> ShellSidebarState.put_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 -> + |> ShellSidebarState.put_filters(fn filters -> filters |> Map.put(:year, parsed_year) |> Map.put(:month, nil) @@ -212,13 +214,13 @@ defmodule BDS.Desktop.ShellLive do 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)} + |> ShellSidebarState.put_filter_panel_state(fn state -> + %{state | archive_collapsed: false, expanded_year: ShellSidebarState.parse_optional_integer(year)} end) - |> put_sidebar_filters(fn filters -> + |> ShellSidebarState.put_filters(fn filters -> filters - |> Map.put(:year, parse_optional_integer(year)) - |> Map.put(:month, parse_optional_integer(month)) + |> Map.put(:year, ShellSidebarState.parse_optional_integer(year)) + |> Map.put(:month, ShellSidebarState.parse_optional_integer(month)) end) |> reload_shell(socket.assigns.workbench)} end @@ -226,22 +228,22 @@ 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) + |> ShellSidebarState.put_filter_panel_state(fn state -> %{state | archive_collapsed: false} end) + |> ShellSidebarState.put_filters(fn filters -> filters |> Map.put(:year, nil) |> Map.put(:month, nil) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("clear_sidebar_filters", _params, socket) do {:noreply, socket - |> put_sidebar_filters(fn filters -> + |> ShellSidebarState.put_filters(fn filters -> filters |> Map.put(:search, nil) |> Map.put(:year, nil) |> Map.put(:month, nil) |> Map.put(:tags, []) |> Map.put(:categories, []) - |> Map.put(:display_limit, sidebar_page_size(socket.assigns.sidebar_data)) + |> Map.put(:display_limit, ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data)) end) |> reload_shell(socket.assigns.workbench)} end @@ -249,8 +251,8 @@ defmodule BDS.Desktop.ShellLive do def handle_event("load_more_sidebar", _params, socket) do {:noreply, socket - |> put_sidebar_filters(fn filters -> - Map.update(filters, :display_limit, sidebar_page_size(socket.assigns.sidebar_data), &(&1 + sidebar_page_size(socket.assigns.sidebar_data))) + |> ShellSidebarState.put_filters(fn filters -> + Map.update(filters, :display_limit, ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data), &(&1 + ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data))) end) |> reload_shell(socket.assigns.workbench)} end @@ -315,51 +317,39 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("change_post_editor", %{"post_editor" => params}, socket) do - {:noreply, update_post_editor(socket, params)} + {:noreply, PostEditor.update(socket, params, &reload_shell/2)} end def handle_event("save_post_editor", %{"id" => post_id}, socket) do - {:noreply, persist_post_editor(socket, post_id, :save)} + {:noreply, PostEditor.persist_socket(socket, post_id, :save, &reload_shell/2, &append_output_entry/5)} end def handle_event("publish_post_editor", %{"id" => post_id}, socket) do - {:noreply, persist_post_editor(socket, post_id, :publish)} + {:noreply, PostEditor.persist_socket(socket, post_id, :publish, &reload_shell/2, &append_output_entry/5)} end def handle_event("discard_post_editor", %{"id" => post_id}, socket) do - {:noreply, discard_post_editor(socket, post_id)} + {:noreply, PostEditor.discard_socket(socket, post_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("delete_post_editor", %{"id" => post_id}, socket) do - {:noreply, delete_post_editor(socket, post_id)} + {:noreply, PostEditor.delete_socket(socket, post_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("set_post_editor_mode", %{"id" => post_id, "mode" => mode}, socket) do - {:noreply, - socket - |> assign(:post_editor_modes, Map.put(socket.assigns.post_editor_modes, post_id, PostEditor.normalize_mode(mode))) - |> reload_shell(socket.assigns.workbench)} + {:noreply, PostEditor.set_mode(socket, post_id, mode, &reload_shell/2)} end def handle_event("toggle_post_metadata", %{"id" => post_id}, socket) do - {:noreply, - socket - |> update_post_editor_expanded(post_id, fn expanded -> Map.update!(expanded, :metadata, ¬ &1) end) - |> reload_shell(socket.assigns.workbench)} + {:noreply, PostEditor.toggle_section(socket, post_id, :metadata, &reload_shell/2)} end def handle_event("toggle_post_excerpt", %{"id" => post_id}, socket) do - {:noreply, - socket - |> update_post_editor_expanded(post_id, fn expanded -> Map.update!(expanded, :excerpt, ¬ &1) end) - |> reload_shell(socket.assigns.workbench)} + {:noreply, PostEditor.toggle_section(socket, post_id, :excerpt, &reload_shell/2)} end def handle_event("select_post_editor_language", %{"id" => post_id, "language" => language}, socket) do - {:noreply, - socket - |> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, PostEditor.normalize_language(language, language))) - |> reload_shell(socket.assigns.workbench)} + {:noreply, PostEditor.select_language(socket, post_id, language, &reload_shell/2)} end def handle_event("open_overlay", %{"kind" => kind}, socket) do @@ -639,8 +629,8 @@ defmodule BDS.Desktop.ShellLive do dashboard = ShellData.dashboard(projects.active_project_id) git_badge_count = ShellData.git_badge_count(projects.active_project_id) active_view_id = Atom.to_string(workbench.active_view) - sidebar_data = ShellData.sidebar_view(projects.active_project_id, active_view_id, current_sidebar_filters(socket, active_view_id)) - sidebar_data = merge_sidebar_ui_state(socket, active_view_id, sidebar_data) + sidebar_data = ShellData.sidebar_view(projects.active_project_id, active_view_id, ShellSidebarState.current_filters(socket, active_view_id)) + sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data) task_status = BDS.Tasks.status_snapshot() activity_buttons = Workbench.activity_buttons(workbench, git_badge_count) page_language = socket.assigns[:page_language] || ShellData.ui_language() @@ -676,395 +666,6 @@ defmodule BDS.Desktop.ShellLive do |> assign_post_editor() end - defp render_sidebar_filters(assigns) do - filters = Map.get(assigns.sidebar_data, :filters) - - if is_map(filters) and Map.get(filters, :enabled) do - selected = Map.get(filters, :selected, %{}) - - assigns = - assigns - |> assign(:sidebar_filters_config, filters) - |> assign(:selected_filters, selected) - |> 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 %> -
- - <%= translated(@sidebar_filters_config.results_label) %>: <%= @sidebar_filters_config.loaded_count %>/<%= @sidebar_filters_config.total_count %> - - -
- <% end %> - - <%= if @filter_panel_visible 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 %> -
- <%= 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 %> -
-
- <%= 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 %> -
-
- <%= 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 %> -
- <% end %> - - """ - else - ~H""" - """ - end - end - - defp render_sidebar_load_more(assigns) do - filters = Map.get(assigns.sidebar_data, :filters, %{}) - - if Map.get(filters, :has_more) do - ~H""" - - """ - else - ~H""" - """ - end - end - - defp render_sidebar_body(assigns) do - case assigns.sidebar_data.layout do - "post_list" -> render_post_sidebar(assigns) - "media_grid" -> render_media_sidebar(assigns) - "entity_list" -> render_entity_sidebar(assigns) - "nav_list" -> render_nav_sidebar(assigns) - _other -> render_default_sidebar(assigns) - end - end - - defp render_post_sidebar(assigns) do - ~H""" - <%= for section <- Map.get(@sidebar_data, :sections, []) do %> - - <% end %> - <%= if Enum.empty?(Map.get(@sidebar_data, :sections, [])) do %> - - <% end %> - """ - end - - defp render_media_sidebar(assigns) do - ~H""" - <%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %> - - <% else %> - - <% end %> - """ - end - - defp render_entity_sidebar(assigns) do - ~H""" - <%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %> -
- <%= for item <- Map.get(@sidebar_data, :items, []) do %> - - <% end %> -
- <% else %> - - <% end %> - """ - end - - defp render_nav_sidebar(assigns) do - ~H""" -
- <%= for item <- Map.get(@sidebar_data, :items, []) do %> - - <% end %> -
- """ - end - - defp render_default_sidebar(assigns) do - ~H""" - <%= for section <- Map.get(@sidebar_data, :sections, []) do %> - - <% end %> - """ - end - defp render_panel_body(assigns) do case assigns.workbench.panel.active_tab do :tasks -> render_task_entries(assigns) @@ -1274,20 +875,6 @@ defmodule BDS.Desktop.ShellLive do max(4, ((entry.count || 0) / max_count) * 100) end - defp format_sidebar_timestamp(nil), do: "" - - defp format_sidebar_timestamp(timestamp) do - timestamp - |> DateTime.from_unix!(:millisecond) - |> Calendar.strftime("%x") - end - - defp image_media?(item), do: String.starts_with?(to_string(item.mime_type || ""), "image/") - - defp media_thumbnail_class(item) do - if image_media?(item), do: "media-thumbnail has-image", else: "media-thumbnail" - end - defp current_tab(%{active_tab: nil}), do: nil defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do @@ -1295,163 +882,7 @@ defmodule BDS.Desktop.ShellLive do end defp assign_post_editor(socket) do - assigns = Map.put(socket.assigns, :project_metadata, ShellOverlayComponents.project_metadata(socket.assigns.projects.active_project_id)) - assign(socket, :post_editor, PostEditor.build(assigns)) - end - - defp update_post_editor(socket, params) do - case socket.assigns.current_tab do - %{type: :post, id: post_id} -> - case Repo.get(Post, post_id) do - nil -> - socket - - %Post{} = post -> - metadata = ShellOverlayComponents.project_metadata(post.project_id) - canonical_language = PostEditor.normalize_language(post.language, metadata.main_language || "en") - current_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) - requested_language = PostEditor.normalize_language(Map.get(params, "language"), current_language) - - next_language = - if current_language == canonical_language do - requested_language - else - current_language - end - - draft = PostEditor.normalize_params(params, current_language, next_language) - workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id) - - socket - |> assign(:workbench, workbench) - |> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft)) - |> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)) - |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) - |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: draft["title"], subtitle: Atom.to_string(post.status || :draft)})) - |> maybe_drop_old_language_draft(post_id, current_language, next_language) - |> reload_shell(workbench) - end - - _other -> - socket - end - end - - defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language) when current_language == next_language, - do: socket - - defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do - assign(socket, :post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language)) - end - - defp persist_post_editor(socket, post_id, action) do - case Repo.get(Post, post_id) do - nil -> - socket - - %Post{} = post -> - metadata = ShellOverlayComponents.project_metadata(post.project_id) - canonical_language = PostEditor.normalize_language(post.language, metadata.main_language || "en") - active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) - draft = PostEditor.current_draft(socket.assigns, post, metadata, active_language) - - result = PostEditor.persist(post, draft, active_language, metadata, action) - - case result do - {:ok, record} -> - workbench = Workbench.clear_dirty(socket.assigns.workbench, :post, post_id) - normalized_form = PostEditor.persisted_form(Repo.get!(Post, post_id), metadata, active_language) - - socket - |> assign(:workbench, workbench) - |> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, normalized_form)) - |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, PostEditor.save_state_for_action(action))) - |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: PostEditor.record_title(record, Repo.get!(Post, post_id)), subtitle: Atom.to_string(PostEditor.record_status(record))})) - |> reload_shell(workbench) - - {:error, reason} -> - socket - |> append_output_entry(translated("Post"), inspect(reason), nil, "error") - |> reload_shell(socket.assigns.workbench) - end - end - end - - defp discard_post_editor(socket, post_id) do - case Repo.get(Post, post_id) do - nil -> - socket - - %Post{} = post -> - metadata = ShellOverlayComponents.project_metadata(post.project_id) - canonical_language = PostEditor.normalize_language(post.language, metadata.main_language || "en") - active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) - restored_result = PostEditor.discard(post, active_language, metadata) - - case restored_result do - {:ok, restored_post} -> - workbench = Workbench.clear_dirty(socket.assigns.workbench, :post, post_id) - - socket - |> assign(:workbench, workbench) - |> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, active_language)) - |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :discarded)) - |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: restored_post.title || restored_post.slug || restored_post.id, subtitle: Atom.to_string(restored_post.status || :draft)})) - |> reload_shell(workbench) - - {:error, reason} -> - socket - |> append_output_entry(translated("Post"), inspect(reason), nil, "error") - |> reload_shell(socket.assigns.workbench) - end - end - end - - defp delete_post_editor(socket, post_id) do - case Posts.delete_post(post_id) do - {:ok, :deleted} -> - workbench = Workbench.close_tab(socket.assigns.workbench, :post, post_id) - - socket - |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id})) - |> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id)) - |> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id)) - |> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id)) - |> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id)) - |> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id)) - |> reload_shell(workbench) - - {:error, reason} -> - socket - |> append_output_entry(translated("Post"), inspect(reason), nil, "error") - |> reload_shell(socket.assigns.workbench) - end - end - - defp update_post_editor_expanded(socket, post_id, updater) do - expanded = - socket.assigns.post_editor_expanded - |> Map.get(post_id, %{metadata: false, excerpt: false}) - |> Map.put_new(:metadata, false) - |> Map.put_new(:excerpt, false) - |> updater.() - - assign(socket, :post_editor_expanded, Map.put(socket.assigns.post_editor_expanded, post_id, expanded)) - end - - defp put_nested_map(map, key, nested_key, value) do - Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value)) - end - - defp delete_nested_map(map, key, nested_key) do - case Map.get(map, key) do - nil -> map - nested -> - case Map.delete(nested, nested_key) do - emptied when map_size(emptied) == 0 -> Map.delete(map, key) - remaining -> Map.put(map, key, remaining) - end - end + PostEditor.assign_socket(socket) end @@ -1519,177 +950,6 @@ defmodule BDS.Desktop.ShellLive do |> reload_shell(workbench) end - defp merge_sidebar_ui_state(socket, view_id, sidebar_data) do - filters = Map.get(sidebar_data, :filters) - - if is_map(filters) and Map.get(filters, :enabled) do - 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, %{}) - |> normalize_sidebar_filters(socket.assigns[:sidebar_data]) - end - - defp normalize_sidebar_filters(filters, sidebar_data) do - max_items = sidebar_page_size(sidebar_data) - - %{ - search: normalize_filter_string(Map.get(filters, :search)), - year: Map.get(filters, :year), - month: Map.get(filters, :month), - tags: Map.get(filters, :tags, []), - categories: Map.get(filters, :categories, []), - display_limit: max(Map.get(filters, :display_limit, max_items) || max_items, max_items) - } - end - - defp put_sidebar_filters(socket, updater) do - view_id = Atom.to_string(socket.assigns.workbench.active_view) - filters = current_sidebar_filters(socket, view_id) |> updater.() |> normalize_sidebar_filters(socket.assigns.sidebar_data) - assign(socket, :sidebar_filters_by_view, Map.put(socket.assigns.sidebar_filters_by_view, view_id, filters)) - end - - defp toggle_filter_value(filters, key, value) do - values = Map.get(filters, key, []) - - next_values = - if value in values do - List.delete(values, value) - else - values ++ [value] - end - - 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 - value - |> to_string() - |> String.trim() - |> case do - "" -> nil - trimmed -> trimmed - end - end - - defp parse_optional_integer(nil), do: nil - defp parse_optional_integer(value) when is_integer(value), do: value - - defp parse_optional_integer(value) when is_binary(value) do - case Integer.parse(value) do - {parsed, _rest} -> parsed - :error -> nil - end - end - - defp sidebar_page_size(nil), do: 500 - - defp sidebar_page_size(sidebar_data) do - sidebar_data - |> Map.get(:filters, %{}) - |> Map.get(:max_items, 500) - end - defp set_page_language(socket, language) do codes = Enum.map(socket.assigns[:supported_ui_languages] || ShellData.supported_ui_languages(), & &1.code) @@ -2043,11 +1303,6 @@ defmodule BDS.Desktop.ShellLive do end end - defp sidebar_item_selected?(workbench, route, id) do - route_atom = sidebar_route_atom(route) - workbench.active_tab == {route_atom, tab_id_for_route(route_atom, id)} - end - defp tab_title(nil, _tab_meta), do: translated("Dashboard") defp tab_title(tab, tab_meta) do @@ -2152,13 +1407,4 @@ defmodule BDS.Desktop.ShellLive do |> assign(:shell_overlay, nil) end - defp media_thumbnail_glyph(mime_type) do - case String.split(to_string(mime_type || ""), "/", parts: 2) do - ["image", _rest] -> "IMG" - ["video", _rest] -> "VID" - ["audio", _rest] -> "AUD" - ["application", _rest] -> "DOC" - _other -> "FILE" - end - end end diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex index 2232ad9..ac6a8cb 100644 --- a/lib/bds/desktop/shell_live/index.html.heex +++ b/lib/bds/desktop/shell_live/index.html.heex @@ -166,12 +166,12 @@ diff --git a/lib/bds/desktop/shell_live/post_editor.ex b/lib/bds/desktop/shell_live/post_editor.ex index 60602b7..88e7186 100644 --- a/lib/bds/desktop/shell_live/post_editor.ex +++ b/lib/bds/desktop/shell_live/post_editor.ex @@ -7,20 +7,168 @@ defmodule BDS.Desktop.ShellLive.PostEditor do import Phoenix.HTML alias BDS.Desktop.ShellData - alias BDS.{I18n, PostLinks, Posts, Repo, Tags, Templates} + alias BDS.{I18n, Metadata, PostLinks, Posts, Repo, Tags, Templates} alias BDS.Media.Media alias BDS.Posts.{Post, Translation} alias BDS.UI.Workbench embed_templates "post_editor_html/*" + def assign_socket(socket) do + assigns = Map.put(socket.assigns, :project_metadata, project_metadata(socket.assigns.projects.active_project_id)) + assign(socket, :post_editor, build(assigns)) + end + + def update(socket, params, reload) do + case socket.assigns.current_tab do + %{type: :post, id: post_id} -> + case Repo.get(Post, post_id) do + nil -> + socket + + %Post{} = post -> + metadata = project_metadata(post.project_id) + canonical_language = canonical_language(post, metadata) + current_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + requested_language = normalize_language(Map.get(params, "language"), current_language) + + next_language = + if current_language == canonical_language do + requested_language + else + current_language + end + + draft = normalize_params(params, current_language, next_language) + workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id) + + socket + |> assign(:workbench, workbench) + |> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft)) + |> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)) + |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) + |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: draft["title"], subtitle: Atom.to_string(post.status || :draft)})) + |> maybe_drop_old_language_draft(post_id, current_language, next_language) + |> reload.(workbench) + end + + _other -> + socket + end + end + + def persist_socket(socket, post_id, action, reload, append_output) do + case Repo.get(Post, post_id) do + nil -> + socket + + %Post{} = post -> + metadata = project_metadata(post.project_id) + canonical_language = canonical_language(post, metadata) + active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + draft = current_draft(socket.assigns, post, metadata, active_language) + + case persist(post, draft, active_language, metadata, action) do + {:ok, record} -> + workbench = Workbench.clear_dirty(socket.assigns.workbench, :post, post_id) + normalized_form = persisted_form(Repo.get!(Post, post_id), metadata, active_language) + + socket + |> assign(:workbench, workbench) + |> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, normalized_form)) + |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, save_state_for_action(action))) + |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: record_title(record, Repo.get!(Post, post_id)), subtitle: Atom.to_string(record_status(record))})) + |> reload.(workbench) + + {:error, reason} -> + socket + |> append_output.(translated("Post"), inspect(reason), nil, "error") + |> reload.(socket.assigns.workbench) + end + end + end + + def discard_socket(socket, post_id, reload, append_output) do + case Repo.get(Post, post_id) do + nil -> + socket + + %Post{} = post -> + metadata = project_metadata(post.project_id) + canonical_language = canonical_language(post, metadata) + active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + + case discard(post, active_language, metadata) do + {:ok, restored_post} -> + workbench = Workbench.clear_dirty(socket.assigns.workbench, :post, post_id) + + socket + |> assign(:workbench, workbench) + |> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, active_language)) + |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :discarded)) + |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: restored_post.title || restored_post.slug || restored_post.id, subtitle: Atom.to_string(restored_post.status || :draft)})) + |> reload.(workbench) + + {:error, reason} -> + socket + |> append_output.(translated("Post"), inspect(reason), nil, "error") + |> reload.(socket.assigns.workbench) + end + end + end + + def delete_socket(socket, post_id, reload, append_output) do + case Posts.delete_post(post_id) do + {:ok, :deleted} -> + workbench = Workbench.close_tab(socket.assigns.workbench, :post, post_id) + + socket + |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id})) + |> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id)) + |> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id)) + |> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id)) + |> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id)) + |> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id)) + |> reload.(workbench) + + {:error, reason} -> + socket + |> append_output.(translated("Post"), inspect(reason), nil, "error") + |> reload.(socket.assigns.workbench) + end + end + + def set_mode(socket, post_id, mode, reload) do + workbench = socket.assigns.workbench + + socket + |> assign(:post_editor_modes, Map.put(socket.assigns.post_editor_modes, post_id, normalize_mode(mode))) + |> reload.(workbench) + end + + def toggle_section(socket, post_id, section, reload) when section in [:metadata, :excerpt] do + workbench = socket.assigns.workbench + + socket + |> assign(:post_editor_expanded, Map.put(socket.assigns.post_editor_expanded, post_id, toggled_sections(socket.assigns.post_editor_expanded, post_id, section))) + |> reload.(workbench) + end + + def select_language(socket, post_id, language, reload) do + workbench = socket.assigns.workbench + + socket + |> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, normalize_language(language, language))) + |> reload.(workbench) + end + def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do case Repo.get(Post, post_id) do nil -> nil %Post{} = post -> - metadata = project_metadata(assigns) + metadata = assigned_project_metadata(assigns) canonical_language = canonical_language(post, metadata) active_language = Map.get(assigns.post_editor_active_languages, post.id, canonical_language) translations = translations(post.id) @@ -165,6 +313,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor do def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) + def project_metadata(nil), do: %{main_language: "en", blog_languages: []} + + def project_metadata(project_id) do + {:ok, metadata} = Metadata.get_project_metadata(project_id) + metadata + rescue + _error -> %{main_language: "en", blog_languages: []} + end + defp editor_toolbar(assigns) do ~H""" <%= if Enum.any?(@toolbar_buttons) do %> @@ -185,7 +342,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do """ end - defp project_metadata(assigns), do: Map.get(assigns, :project_metadata, %{}) + defp assigned_project_metadata(assigns), do: Map.get(assigns, :project_metadata, %{}) defp current_status(post_status, active_language, canonical_language, current_translation) do if active_language == canonical_language, do: post_status, else: translation_status(current_translation) @@ -401,4 +558,34 @@ defmodule BDS.Desktop.ShellLive.PostEditor do defp maybe_publish_translation({:ok, _translation}, post_id, language, :publish), do: Posts.publish_post_translation(post_id, language) defp maybe_publish_translation(result, _post_id, _language, _action), do: result + + defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language) when current_language == next_language, + do: socket + + defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do + assign(socket, :post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language)) + end + + defp toggled_sections(expanded_by_post, post_id, section) do + expanded_by_post + |> Map.get(post_id, %{metadata: false, excerpt: false}) + |> Map.put_new(:metadata, false) + |> Map.put_new(:excerpt, false) + |> Map.update!(section, ¬ &1) + end + + defp put_nested_map(map, key, nested_key, value) do + Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value)) + end + + defp delete_nested_map(map, key, nested_key) do + case Map.get(map, key) do + nil -> map + nested -> + case Map.delete(nested, nested_key) do + emptied when map_size(emptied) == 0 -> Map.delete(map, key) + remaining -> Map.put(map, key, remaining) + end + end + end end \ No newline at end of file diff --git a/lib/bds/desktop/shell_live/sidebar_components.ex b/lib/bds/desktop/shell_live/sidebar_components.ex new file mode 100644 index 0000000..96a17a6 --- /dev/null +++ b/lib/bds/desktop/shell_live/sidebar_components.ex @@ -0,0 +1,514 @@ +defmodule BDS.Desktop.ShellLive.SidebarComponents do + @moduledoc false + + use Phoenix.Component + + alias BDS.Desktop.ShellData + alias BDS.UI.Registry + + def sidebar_content(assigns) do + Process.put(:bds_ui_locale, assigns.page_language) + assigns = prepare_filter_assigns(assigns) + + ~H""" + <%= render_sidebar_filters(assigns) %> + <%= render_sidebar_body(assigns) %> + <%= render_sidebar_load_more(assigns) %> + """ + end + + def filters_enabled?(sidebar_data) do + sidebar_data + |> Map.get(:filters) + |> then(&(is_map(&1) and Map.get(&1, :enabled, false))) + end + + def filters_visible?(sidebar_data) do + sidebar_data + |> Map.get(:filters, %{}) + |> Map.get(:filter_panel_visible, false) + end + + defp prepare_filter_assigns(assigns) do + filters = Map.get(assigns.sidebar_data, :filters) + + if is_map(filters) and Map.get(filters, :enabled) do + selected = Map.get(filters, :selected, %{}) + + assigns + |> assign(:sidebar_filters_config, filters) + |> assign(:selected_filters, selected) + |> 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, []))) + else + assigns + end + end + + defp render_sidebar_filters(assigns) do + filters = Map.get(assigns.sidebar_data, :filters) + + if is_map(filters) and Map.get(filters, :enabled) do + ~H""" + + + <%= if Map.get(@sidebar_filters_config, :has_active_filters) do %> +
+ + <%= translated(@sidebar_filters_config.results_label) %>: <%= @sidebar_filters_config.loaded_count %>/<%= @sidebar_filters_config.total_count %> + + +
+ <% end %> + + <%= if @filter_panel_visible 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 %> +
+ <%= 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 %> +
+
+ <%= 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 %> +
+
+ <%= 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 %> +
+ <% end %> + """ + else + ~H""" + """ + end + end + + defp render_sidebar_load_more(assigns) do + filters = Map.get(assigns.sidebar_data, :filters, %{}) + + if Map.get(filters, :has_more) do + ~H""" + + """ + else + ~H""" + """ + end + end + + defp render_sidebar_body(assigns) do + case assigns.sidebar_data.layout do + "post_list" -> render_post_sidebar(assigns) + "media_grid" -> render_media_sidebar(assigns) + "entity_list" -> render_entity_sidebar(assigns) + "nav_list" -> render_nav_sidebar(assigns) + _other -> render_default_sidebar(assigns) + end + end + + defp render_post_sidebar(assigns) do + ~H""" + <%= for section <- Map.get(@sidebar_data, :sections, []) do %> + + <% end %> + <%= if Enum.empty?(Map.get(@sidebar_data, :sections, [])) do %> + + <% end %> + """ + end + + defp render_media_sidebar(assigns) do + ~H""" + <%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %> + + <% else %> + + <% end %> + """ + end + + defp render_entity_sidebar(assigns) do + ~H""" + <%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %> +
+ <%= for item <- Map.get(@sidebar_data, :items, []) do %> + + <% end %> +
+ <% else %> + + <% end %> + """ + end + + defp render_nav_sidebar(assigns) do + ~H""" +
+ <%= for item <- Map.get(@sidebar_data, :items, []) do %> + + <% end %> +
+ """ + end + + defp render_default_sidebar(assigns) do + ~H""" + <%= for section <- Map.get(@sidebar_data, :sections, []) do %> + + <% end %> + """ + end + + defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) + + 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 format_sidebar_timestamp(nil), do: "" + + defp format_sidebar_timestamp(timestamp) do + timestamp + |> DateTime.from_unix!(:millisecond) + |> Calendar.strftime("%x") + end + + defp image_media?(item), do: String.starts_with?(to_string(item.mime_type || ""), "image/") + + defp media_thumbnail_class(item) do + if image_media?(item), do: "media-thumbnail has-image", else: "media-thumbnail" + end + + defp media_thumbnail_glyph(mime_type) do + case String.split(to_string(mime_type || ""), "/", parts: 2) do + ["image", _rest] -> "IMG" + ["video", _rest] -> "VID" + ["audio", _rest] -> "AUD" + ["application", _rest] -> "DOC" + _other -> "FILE" + end + end + + defp sidebar_item_selected?(workbench, route, id) do + route_atom = sidebar_route_atom(route) + workbench.active_tab == {route_atom, tab_id_for_route(route_atom, id)} + end + + defp sidebar_route_atom(route) when is_atom(route), do: route + defp sidebar_route_atom(route) when is_binary(route), do: String.to_existing_atom(route) + + defp tab_id_for_route(route, id) do + case Registry.editor_route(route) do + %{singleton: true} -> Atom.to_string(route) + _other -> id + end + end +end \ No newline at end of file diff --git a/lib/bds/desktop/shell_live/sidebar_state.ex b/lib/bds/desktop/shell_live/sidebar_state.ex new file mode 100644 index 0000000..d427a30 --- /dev/null +++ b/lib/bds/desktop/shell_live/sidebar_state.ex @@ -0,0 +1,115 @@ +defmodule BDS.Desktop.ShellLive.SidebarState do + @moduledoc false + + def merge_ui_state(socket, view_id, sidebar_data) do + filters = Map.get(sidebar_data, :filters) + + if is_map(filters) and Map.get(filters, :enabled) do + panel_state = 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 + + def put_filter_panel_state(socket, updater) do + view_id = Atom.to_string(socket.assigns.workbench.active_view) + state = socket |> filter_panel_state(view_id) |> updater.() + Phoenix.Component.assign(socket, :sidebar_filter_panels, Map.put(socket.assigns.sidebar_filter_panels, view_id, state)) + end + + def current_filters(socket, view_id) do + socket.assigns.sidebar_filters_by_view + |> Map.get(view_id, %{}) + |> normalize_filters(socket.assigns[:sidebar_data]) + end + + def put_filters(socket, updater) do + view_id = Atom.to_string(socket.assigns.workbench.active_view) + filters = current_filters(socket, view_id) |> updater.() |> normalize_filters(socket.assigns.sidebar_data) + Phoenix.Component.assign(socket, :sidebar_filters_by_view, Map.put(socket.assigns.sidebar_filters_by_view, view_id, filters)) + end + + def toggle_filter_value(filters, key, value) do + values = Map.get(filters, key, []) + + next_values = + if value in values do + List.delete(values, value) + else + values ++ [value] + end + + Map.put(filters, key, next_values) + end + + def normalize_filter_string(nil), do: nil + + def normalize_filter_string(value) do + value + |> to_string() + |> String.trim() + |> case do + "" -> nil + trimmed -> trimmed + end + end + + def parse_optional_integer(nil), do: nil + def parse_optional_integer(value) when is_integer(value), do: value + + def parse_optional_integer(value) when is_binary(value) do + case Integer.parse(value) do + {parsed, _rest} -> parsed + :error -> nil + end + end + + def sidebar_page_size(nil), do: 500 + + def sidebar_page_size(sidebar_data) do + sidebar_data + |> Map.get(:filters, %{}) + |> Map.get(:max_items, 500) + end + + defp filter_panel_state(socket, view_id) do + default_state = default_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 default_filter_panel_state do + %{ + visible: false, + archive_collapsed: true, + tags_collapsed: true, + categories_collapsed: true, + expanded_year: nil + } + end + + defp normalize_filters(filters, sidebar_data) do + max_items = sidebar_page_size(sidebar_data) + + %{ + search: normalize_filter_string(Map.get(filters, :search)), + year: Map.get(filters, :year), + month: Map.get(filters, :month), + tags: Map.get(filters, :tags, []), + categories: Map.get(filters, :categories, []), + display_limit: max(Map.get(filters, :display_limit, max_items) || max_items, max_items) + } + end +end \ No newline at end of file diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index 7437eeb..2c910d4 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -300,6 +300,46 @@ defmodule BDS.UI.ShellTest do assert css =~ ".lightbox-overlay" end + test "desktop shell keeps post editor logic in the feature slice" do + live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex") + template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex") + post_editor_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/post_editor.ex") + + assert template =~ "