diff --git a/lib/bds/desktop/overlay.ex b/lib/bds/desktop/overlay.ex new file mode 100644 index 0000000..a9a0591 --- /dev/null +++ b/lib/bds/desktop/overlay.ex @@ -0,0 +1,314 @@ +defmodule BDS.Desktop.Overlay do + @moduledoc false + + def open(:post, :ai_suggestions, context) do + %{ + kind: :ai_suggestions, + title: Map.get(context, :ai_title, "AI Suggestions"), + fields: normalize_ai_fields(Map.get(context, :ai_fields, [])) + } + end + + def open(:media, :ai_suggestions, context), do: open(:post, :ai_suggestions, context) + + def open(:post, :insert_link, context) do + posts = related_posts(Map.get(context, :posts, []), current_id(context)) + + %{ + kind: :insert_link, + title: Map.get(context, :insert_link_title, "Insert Link"), + active_tab: :internal, + search_query: "", + external_url: "", + external_text: current_title(context), + results: [], + related_posts: Enum.map(Enum.take(posts, 5), &to_insert_link_result/1), + all_posts: posts + } + end + + def open(:post, :insert_media, context) do + media = Map.get(context, :media, []) + + %{ + kind: :insert_media, + title: Map.get(context, :insert_media_title, "Insert Media"), + search_query: "", + results: Enum.map(media, &to_insert_media_result/1), + all_media: media + } + end + + def open(:post, :language_picker, context) do + language_picker(context, Map.get(context, :current_post_language, "en")) + end + + def open(:media, :language_picker, context) do + language_picker(context, Map.get(context, :current_media_language, "en")) + end + + def open(:media, :confirm_delete, context) do + delete_details = Map.get(context, :delete_details, %{}) + + %{ + kind: :confirm_delete, + title: Map.get(delete_details, :title, "Delete"), + entity_name: Map.get(delete_details, :entity_name, ""), + entity_type: Map.get(delete_details, :entity_type, "media"), + reference_count: length(Map.get(delete_details, :reference_list, [])), + reference_list: Map.get(delete_details, :reference_list, []) + } + end + + def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context) + + def open(:tags, :confirm_merge, context) do + merge = Map.get(context, :merge_details, %{}) + target = Map.get(merge, :target, "") + count = Map.get(merge, :count, 0) + + %{ + kind: :confirm_dialog, + title: Map.get(merge, :title, "Merge #{count} tags into #{target}?"), + message: Map.get(merge, :message, "Cannot be undone.") + } + end + + def open(:post, :gallery, context) do + images = + context + |> gallery_images() + |> Enum.map(&to_gallery_image/1) + + %{ + kind: :gallery, + title: Map.get(context, :gallery_title, current_title(context)), + post_id: current_id(context), + images: images, + lightbox: nil + } + end + + def open(_route, _action, _context), do: nil + + def set_search_query(%{kind: :insert_link} = overlay, query) do + normalized = normalize_query(query) + + results = + if String.length(normalized) < 2 do + [] + else + overlay + |> Map.get(:all_posts, []) + |> Enum.filter(&search_matches?(&1.title, normalized)) + |> Enum.map(&to_insert_link_result/1) + end + + %{overlay | search_query: normalized, results: results} + end + + def set_search_query(%{kind: :insert_media} = overlay, query) do + normalized = normalize_query(query) + + results = + overlay + |> Map.get(:all_media, []) + |> Enum.filter(fn media -> + normalized == "" or + search_matches?(Map.get(media, :title, ""), normalized) or + search_matches?(Map.get(media, :original_name, ""), normalized) + end) + |> Enum.map(&to_insert_media_result/1) + + %{overlay | search_query: normalized, results: results} + end + + def set_search_query(overlay, _query), do: overlay + + def set_active_tab(%{kind: :insert_link} = overlay, tab) when tab in [:internal, :external] do + %{overlay | active_tab: tab} + end + + def set_active_tab(overlay, _tab), do: overlay + + def update_form_value(%{kind: :insert_link} = overlay, :external_url, value) do + %{overlay | external_url: normalize_query(value)} + end + + def update_form_value(%{kind: :insert_link} = overlay, :external_text, value) do + %{overlay | external_text: to_string(value || "")} + end + + def update_form_value(overlay, _key, _value), do: overlay + + def toggle_ai_field(%{kind: :ai_suggestions} = overlay, key) do + fields = + Enum.map(overlay.fields, fn field -> + if field.key == key and not field.locked do + %{field | accepted: not field.accepted} + else + field + end + end) + + %{overlay | fields: fields} + end + + def toggle_ai_field(overlay, _key), do: overlay + + def select_gallery_image(%{kind: :gallery} = overlay, media_id) do + case Enum.find_index(overlay.images, &(&1.media_id == media_id)) do + nil -> overlay + index -> %{overlay | lightbox: lightbox_from_index(overlay.images, index)} + end + end + + def select_gallery_image(overlay, _media_id), do: overlay + + def close_lightbox(%{kind: :gallery} = overlay), do: %{overlay | lightbox: nil} + def close_lightbox(overlay), do: overlay + + def lightbox_next(%{kind: :gallery, lightbox: lightbox, images: images} = overlay) when is_map(lightbox) and images != [] do + next_index = rem(lightbox.current_index + 1, length(images)) + %{overlay | lightbox: lightbox_from_index(images, next_index)} + end + + def lightbox_next(overlay), do: overlay + + def lightbox_previous(%{kind: :gallery, lightbox: lightbox, images: images} = overlay) when is_map(lightbox) and images != [] do + next_index = rem(lightbox.current_index - 1 + length(images), length(images)) + %{overlay | lightbox: lightbox_from_index(images, next_index)} + end + + def lightbox_previous(overlay), do: overlay + + def selected_ai_fields(%{kind: :ai_suggestions, fields: fields}) do + Enum.filter(fields, & &1.accepted) + end + + def selected_ai_fields(_overlay), do: [] + + def insert_link_result(%{kind: :insert_link} = overlay, post_id) do + Enum.find(overlay.results ++ overlay.related_posts, &(&1.post_id == post_id)) + end + + def insert_link_result(_overlay, _post_id), do: nil + + def insert_media_result(%{kind: :insert_media} = overlay, media_id) do + Enum.find(overlay.results, &(&1.media_id == media_id)) + end + + def insert_media_result(_overlay, _media_id), do: nil + + defp language_picker(context, source_language) do + targets = + context + |> Map.get(:blog_languages, []) + |> Enum.uniq() + |> Enum.reject(&(&1 == source_language)) + |> Enum.map(fn code -> + existing_status = Map.get(Map.get(context, :existing_translations, %{}), code) + + %{ + code: code, + name: Map.get(Map.get(context, :language_names, %{}), code, String.upcase(code)), + flag_emoji: Map.get(Map.get(context, :language_flags, %{}), code, code), + has_existing_translation: not is_nil(existing_status), + existing_status: existing_status + } + end) + + %{ + kind: :language_picker, + title: Map.get(context, :language_picker_title, "Translate"), + source_language: source_language, + available_targets: targets + } + end + + defp normalize_ai_fields(fields) do + Enum.map(fields, fn field -> + %{ + key: to_string(Map.get(field, :key, "")), + label: Map.get(field, :label, ""), + current_value: Map.get(field, :current_value, ""), + suggested_value: Map.get(field, :suggested_value, ""), + accepted: not Map.get(field, :locked, false), + locked: Map.get(field, :locked, false) + } + end) + end + + defp current_id(context), do: get_in(context, [:current_tab, :id]) + defp current_title(context), do: get_in(context, [:current_tab, :title]) || "" + + defp related_posts(posts, current_post_id) do + Enum.reject(posts, &(&1.id == current_post_id)) + end + + defp gallery_images(context) do + images = Enum.filter(Map.get(context, :media, []), &Map.get(&1, :is_image, false)) + post_media_ids = Map.get(context, :post_media_ids, []) + + case Enum.filter(images, &(&1.id in post_media_ids)) do + [] -> images + linked -> linked + end + end + + defp to_insert_link_result(post) do + %{ + post_id: post.id, + title: post.title, + status: to_string(Map.get(post, :status, "draft")), + canonical_url: Map.get(post, :canonical_url, "/posts/#{post.id}"), + similarity_score: Map.get(post, :similarity_score) + } + end + + defp to_insert_media_result(media) do + %{ + media_id: media.id, + title: Map.get(media, :title, ""), + original_name: Map.get(media, :original_name, media.id), + is_image: Map.get(media, :is_image, false), + thumbnail_url: Map.get(media, :thumbnail_url) + } + end + + defp to_gallery_image(media) do + %{ + media_id: media.id, + thumbnail_url: Map.get(media, :thumbnail_url), + image_url: Map.get(media, :image_url, Map.get(media, :thumbnail_url)), + alt_text: Map.get(media, :alt_text), + title: Map.get(media, :title, Map.get(media, :original_name, media.id)) + } + end + + defp lightbox_from_index(images, index) do + image = Enum.at(images, index) + + %{ + current_index: index, + total_count: length(images), + media_id: image.media_id, + image_url: image.image_url, + alt_text: image.alt_text, + title: image.title + } + end + + defp search_matches?(value, query) do + value + |> to_string() + |> String.downcase() + |> String.contains?(String.downcase(query)) + end + + defp normalize_query(value) do + value + |> to_string() + |> String.trim() + end +end diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index a4b56f9..1d0753a 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -3,16 +3,18 @@ defmodule BDS.Desktop.ShellLive do use Phoenix.LiveView + import Ecto.Query import Phoenix.HTML - alias BDS.Desktop.{FolderPicker, ShellCommands, ShellData} + alias BDS.Desktop.{FolderPicker, Overlay, ShellCommands, ShellData} alias BDS.Desktop.MenuBar, as: DesktopMenuBar - alias BDS.Git + alias BDS.{Git, I18n, Metadata} alias BDS.Media.Media alias BDS.PostLinks - alias BDS.Posts.Post + alias BDS.Posts.{Post, Translation} alias BDS.Projects alias BDS.Repo + alias BDS.Tags.Tag alias BDS.UI.{Commands, MenuBar, Registry, Session, Workbench} @refresh_interval 1_500 @@ -57,6 +59,7 @@ defmodule BDS.Desktop.ShellLive do |> assign(:project_menu_open, false) |> assign(:sidebar_filters_by_view, %{}) |> assign(:sidebar_filter_panels, %{}) + |> assign(:shell_overlay, nil) |> assign(:output_entries, []) |> reload_shell(workbench)} end @@ -306,6 +309,157 @@ defmodule BDS.Desktop.ShellLive do {:noreply, reload_shell(socket, workbench)} end + def handle_event("open_overlay", %{"kind" => kind}, socket) do + overlay = + with overlay_kind when not is_nil(overlay_kind) <- overlay_kind(kind), + %{type: route} <- socket.assigns[:current_tab] do + Overlay.open(route, overlay_kind, overlay_context(socket)) + end + + {:noreply, assign(socket, :shell_overlay, overlay)} + end + + def handle_event("close_overlay", _params, socket) do + {:noreply, assign(socket, :shell_overlay, nil)} + end + + def handle_event("overlay_keydown", %{"key" => key}, socket) do + socket = + case {socket.assigns[:shell_overlay], key} do + {nil, _other} -> socket + {_overlay, "Escape"} -> assign(socket, :shell_overlay, nil) + {%{kind: :gallery} = overlay, "ArrowLeft"} -> assign(socket, :shell_overlay, Overlay.lightbox_previous(overlay)) + {%{kind: :gallery} = overlay, "ArrowRight"} -> assign(socket, :shell_overlay, Overlay.lightbox_next(overlay)) + _other -> socket + end + + {:noreply, socket} + end + + def handle_event("overlay_toggle_ai_field", %{"key" => key}, socket) do + {:noreply, update_shell_overlay(socket, &Overlay.toggle_ai_field(&1, key))} + end + + def handle_event("overlay_set_search", %{"overlay" => %{"query" => query}}, socket) do + {:noreply, update_shell_overlay(socket, &Overlay.set_search_query(&1, query))} + end + + def handle_event("overlay_set_tab", %{"tab" => tab}, socket) do + {:noreply, update_shell_overlay(socket, &Overlay.set_active_tab(&1, overlay_tab(tab)))} + end + + def handle_event("overlay_update_form", %{"overlay" => params}, socket) do + socket = + socket + |> update_shell_overlay(&Overlay.update_form_value(&1, :external_url, Map.get(params, "url", ""))) + |> update_shell_overlay(&Overlay.update_form_value(&1, :external_text, Map.get(params, "text", ""))) + + {:noreply, socket} + end + + def handle_event("overlay_select_result", %{"id" => id}, socket) do + overlay = socket.assigns[:shell_overlay] + + socket = + case overlay do + %{kind: :insert_link} -> + case Overlay.insert_link_result(overlay, id) do + nil -> socket + result -> close_overlay_with_output(socket, overlay.title, markdown_link(result.title, result.canonical_url)) + end + + %{kind: :insert_media} -> + case Overlay.insert_media_result(overlay, id) do + nil -> socket + result -> + syntax = + if result.is_image do + "![#{result.title}](bds-media://#{result.media_id})" + else + "[#{result.original_name}](bds-media://#{result.media_id})" + end + + close_overlay_with_output(socket, overlay.title, syntax) + end + + _other -> + socket + end + + {:noreply, socket} + end + + def handle_event("overlay_insert_external", _params, socket) do + socket = + case socket.assigns[:shell_overlay] do + %{kind: :insert_link} = overlay -> + details = + case {overlay.external_url, String.trim(overlay.external_text || "")} do + {"", _text} -> nil + {url, ""} -> url + {url, text} -> markdown_link(text, url) + end + + if details do + close_overlay_with_output(socket, overlay.title, details) + else + socket + end + + _other -> + socket + end + + {:noreply, socket} + end + + def handle_event("overlay_select_language", %{"code" => code}, socket) do + socket = + case socket.assigns[:shell_overlay] do + %{kind: :language_picker, title: title} -> close_overlay_with_output(socket, title, code) + _other -> socket + end + + {:noreply, socket} + end + + def handle_event("overlay_confirm", _params, socket) do + socket = + case socket.assigns[:shell_overlay] do + %{kind: :ai_suggestions, title: title} = overlay -> + selected = Overlay.selected_ai_fields(overlay) + details = Enum.map_join(selected, ", ", & &1.label) + close_overlay_with_output(socket, title, details) + + %{kind: :confirm_delete, title: title, entity_name: entity_name} -> + close_overlay_with_output(socket, title, entity_name) + + %{kind: :confirm_dialog, title: title, message: message} -> + close_overlay_with_output(socket, title, message) + + _other -> + socket + end + + {:noreply, socket} + end + + def handle_event("overlay_select_gallery_image", %{"id" => id}, socket) do + {:noreply, update_shell_overlay(socket, &Overlay.select_gallery_image(&1, id))} + end + + def handle_event("overlay_close_lightbox", _params, socket) do + {:noreply, update_shell_overlay(socket, &Overlay.close_lightbox/1)} + end + + def handle_event("overlay_lightbox_previous", _params, socket) do + {:noreply, update_shell_overlay(socket, &Overlay.lightbox_previous/1)} + end + + def handle_event("overlay_lightbox_next", _params, socket) do + {:noreply, update_shell_overlay(socket, &Overlay.lightbox_next/1)} + end + def handle_event("toggle_project_menu", _params, socket) do {:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)} end @@ -864,6 +1018,286 @@ defmodule BDS.Desktop.ShellLive do end end + defp render_editor_toolbar(assigns) do + buttons = editor_toolbar_buttons(assigns.current_tab) + assigns = assign(assigns, :editor_toolbar_buttons, buttons) + + ~H""" + <%= if Enum.any?(@editor_toolbar_buttons) do %> +
+ <%= for button <- @editor_toolbar_buttons do %> + + <% end %> +
+ <% end %> + """ + end + + defp render_shell_overlay(%{shell_overlay: nil} = assigns) do + ~H""" + """ + end + + defp render_shell_overlay(assigns) do + case assigns.shell_overlay.kind do + :ai_suggestions -> render_ai_suggestions_overlay(assigns) + :insert_link -> render_insert_link_overlay(assigns) + :insert_media -> render_insert_media_overlay(assigns) + :language_picker -> render_language_picker_overlay(assigns) + :confirm_delete -> render_confirm_delete_overlay(assigns) + :confirm_dialog -> render_confirm_dialog_overlay(assigns) + :gallery -> render_gallery_overlay(assigns) + _other -> ~H""" + """ + end + end + + defp render_ai_suggestions_overlay(assigns) do + ~H""" +
+ + +
+ """ + end + + defp render_insert_link_overlay(assigns) do + ~H""" +
+ + +
+ """ + end + + defp render_insert_media_overlay(assigns) do + ~H""" +
+ + +
+ """ + end + + defp render_language_picker_overlay(assigns) do + ~H""" +
+ + +
+ """ + end + + defp render_confirm_delete_overlay(assigns) do + ~H""" +
+ + +
+ """ + end + + defp render_confirm_dialog_overlay(assigns) do + ~H""" +
+ + +
+ """ + end + + defp render_gallery_overlay(assigns) do + ~H""" + + """ + end + defp render_task_entries(assigns) do ~H""" <%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %> @@ -1681,6 +2115,35 @@ defmodule BDS.Desktop.ShellLive do defp tab_route_label(nil), do: translated("Dashboard") defp tab_route_label(%{type: type}), do: ShellData.route_label(type) + defp editor_toolbar_buttons(nil), do: [] + + defp editor_toolbar_buttons(%{type: :post}) do + [ + %{kind: "ai_suggestions", label: "AI Suggestions", destructive: false}, + %{kind: "insert_link", label: "Insert Link", destructive: false}, + %{kind: "insert_media", label: "Insert Media", destructive: false}, + %{kind: "language_picker", label: "Translate", destructive: false}, + %{kind: "gallery", label: "Gallery", destructive: false} + ] + end + + defp editor_toolbar_buttons(%{type: :media}) do + [ + %{kind: "ai_suggestions", label: "AI Suggestions", destructive: false}, + %{kind: "language_picker", label: "Translate", destructive: false}, + %{kind: "confirm_delete", label: "Delete Media", destructive: true} + ] + end + + defp editor_toolbar_buttons(%{type: :tags}) do + [ + %{kind: "confirm_merge", label: "Merge Tags", destructive: false}, + %{kind: "confirm_delete", label: "Delete Tag", destructive: true} + ] + end + + defp editor_toolbar_buttons(_tab), do: [] + defp tab_icon_id(nil), do: "posts" defp tab_icon_id(%{type: :post}), do: "posts" defp tab_icon_id(%{type: :git_diff}), do: "git" @@ -1715,6 +2178,292 @@ defmodule BDS.Desktop.ShellLive do defp assistant_message_testid(role), do: "assistant-message-#{role}" + defp overlay_context(socket) do + project_id = socket.assigns.projects.active_project_id + metadata = overlay_project_metadata(project_id) + current_tab = socket.assigns.current_tab + page_language = socket.assigns.page_language + tab_title = tab_title(current_tab, socket.assigns.tab_meta) + tab_subtitle = tab_subtitle(current_tab, socket.assigns.tab_meta) + posts = overlay_posts(project_id) + media = overlay_media(project_id) + + %{ + current_tab: %{type: current_tab.type, id: current_tab.id, title: tab_title, subtitle: tab_subtitle}, + current_post_language: overlay_source_language(current_tab, metadata), + current_media_language: overlay_source_language(current_tab, metadata), + posts: posts, + media: media, + post_media_ids: overlay_post_media_ids(current_tab), + blog_languages: overlay_blog_languages(metadata), + language_names: overlay_language_names(), + language_flags: overlay_language_flags(), + existing_translations: overlay_existing_translations(current_tab), + ai_title: ShellData.translate("AI Suggestions", %{}, page_language), + insert_link_title: ShellData.translate("Insert Link", %{}, page_language), + insert_media_title: ShellData.translate("Insert Media", %{}, page_language), + language_picker_title: ShellData.translate("Translate", %{}, page_language), + gallery_title: tab_title, + ai_fields: overlay_ai_fields(current_tab, tab_title, tab_subtitle, page_language), + delete_details: overlay_delete_details(current_tab, page_language), + merge_details: overlay_merge_details(project_id, page_language) + } + end + + defp overlay_project_metadata(nil), do: %{main_language: "en", blog_languages: []} + + defp overlay_project_metadata(project_id) do + case Metadata.get_project_metadata(project_id) do + {:ok, metadata} -> metadata + _other -> %{main_language: "en", blog_languages: []} + end + rescue + _error -> %{main_language: "en", blog_languages: []} + end + + defp overlay_posts(nil), do: [] + + defp overlay_posts(project_id) do + Repo.all( + from post in Post, + where: post.project_id == ^project_id, + order_by: [desc: post.updated_at, desc: post.created_at], + select: %{id: post.id, title: post.title, slug: post.slug, status: post.status, published_at: post.published_at, updated_at: post.updated_at, language: post.language} + ) + |> Enum.map(fn post -> + %{ + id: post.id, + title: post.title || post.slug || post.id, + status: Atom.to_string(post.status || :draft), + canonical_url: canonical_post_url(post) + } + end) + end + + defp overlay_media(nil), do: [] + + defp overlay_media(project_id) do + Repo.all( + from media in Media, + where: media.project_id == ^project_id, + order_by: [desc: media.updated_at, desc: media.created_at], + select: %{id: media.id, title: media.title, original_name: media.original_name, mime_type: media.mime_type, alt: media.alt, caption: media.caption} + ) + |> Enum.map(fn media -> + %{ + id: media.id, + title: media.title || media.original_name || media.id, + original_name: media.original_name || media.id, + is_image: String.starts_with?(to_string(media.mime_type || ""), "image/"), + thumbnail_url: "/media-thumbnail/#{media.id}", + image_url: "/media-thumbnail/#{media.id}?size=large", + alt_text: media.alt || media.caption || media.title + } + end) + end + + defp overlay_post_media_ids(%{type: :post, id: post_id}) do + case Repo.query("SELECT media_id FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do + {:ok, %{rows: rows}} -> Enum.map(rows, fn [media_id] -> media_id end) + _other -> [] + end + rescue + _error -> [] + end + + defp overlay_post_media_ids(_tab), do: [] + + defp overlay_existing_translations(%{type: :post, id: post_id}) do + Repo.all( + from translation in Translation, + where: translation.translation_for == ^post_id, + select: {translation.language, translation.status} + ) + |> Map.new(fn {language, status} -> {language, Atom.to_string(status || :draft)} end) + rescue + _error -> %{} + end + + defp overlay_existing_translations(_tab), do: %{} + + defp overlay_blog_languages(metadata) do + ([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp overlay_source_language(%{type: :post, id: post_id}, metadata) do + case Repo.get(Post, post_id) do + %Post{language: language} when is_binary(language) and language != "" -> language + _other -> metadata.main_language || "en" + end + rescue + _error -> metadata.main_language || "en" + end + + defp overlay_source_language(_tab, metadata), do: metadata.main_language || "en" + + defp overlay_language_names do + %{ + "en" => "English", + "de" => "Deutsch", + "fr" => "Francais", + "it" => "Italiano", + "es" => "Espanol" + } + end + + defp overlay_language_flags do + I18n.supported_languages() + |> Enum.into(%{}, fn language -> {language.code, I18n.flag(language.code)} end) + end + + defp overlay_ai_fields(%{type: :post, id: post_id}, title, subtitle, page_language) do + case Repo.get(Post, post_id) do + %Post{} = post -> + [ + %{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: post.title || title, suggested_value: refine_title(post.title || title), locked: false}, + %{key: "excerpt", label: ShellData.translate("Excerpt", %{}, page_language), current_value: post.excerpt || subtitle, suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle), locked: false}, + %{key: "slug", label: ShellData.translate("Slug", %{}, page_language), current_value: post.slug || slugify(post.title || title), suggested_value: refine_slug(post.slug || slugify(post.title || title)), locked: post.status == :published} + ] + + _other -> + [] + end + rescue + _error -> [] + end + + defp overlay_ai_fields(%{type: :media, id: media_id}, title, _subtitle, page_language) do + case Repo.get(Media, media_id) do + %Media{} = media -> + [ + %{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: media.title || title, suggested_value: refine_title(media.title || title), locked: false}, + %{key: "alt", label: ShellData.translate("Alt Text", %{}, page_language), current_value: media.alt || "", suggested_value: media.alt || title, locked: false}, + %{key: "caption", label: ShellData.translate("Caption", %{}, page_language), current_value: media.caption || "", suggested_value: refine_excerpt(title, media.caption || title), locked: false} + ] + + _other -> + [] + end + rescue + _error -> [] + end + + defp overlay_ai_fields(_tab, _title, _subtitle, _page_language), do: [] + + defp overlay_delete_details(%{type: :media, id: media_id}, page_language) do + entity_name = + case Repo.get(Media, media_id) do + %Media{} = media -> media.title || media.original_name || media.id + _other -> media_id + end + + reference_list = + case Repo.query("SELECT posts.title FROM posts JOIN post_media ON posts.id = post_media.post_id WHERE post_media.media_id = ? ORDER BY post_media.sort_order ASC, posts.updated_at DESC", [media_id]) do + {:ok, %{rows: rows}} -> Enum.map(rows, fn [title] -> title || media_id end) + _other -> [] + end + + %{ + title: ShellData.translate("Delete Media", %{}, page_language), + entity_name: entity_name, + entity_type: "media", + reference_list: reference_list + } + rescue + _error -> %{title: ShellData.translate("Delete Media", %{}, page_language), entity_name: media_id, entity_type: "media", reference_list: []} + end + + defp overlay_delete_details(%{type: :tags}, page_language) do + tag_name = + Repo.one(from tag in Tag, order_by: [asc: tag.name], limit: 1, select: tag.name) + |> Kernel.||("tag") + + %{ + title: ShellData.translate("Delete Tag", %{}, page_language), + entity_name: tag_name, + entity_type: "tag", + reference_list: [] + } + rescue + _error -> %{title: ShellData.translate("Delete Tag", %{}, page_language), entity_name: "tag", entity_type: "tag", reference_list: []} + end + + defp overlay_delete_details(_tab, page_language) do + %{title: ShellData.translate("Delete", %{}, page_language), entity_name: "", entity_type: "item", reference_list: []} + end + + defp overlay_merge_details(project_id, page_language) do + tags = + Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name], limit: 3, select: tag.name) + + target = List.first(tags) || "tag" + + %{ + target: target, + count: max(length(tags), 1), + title: ShellData.translate("Merge Tags", %{}, page_language), + message: ShellData.translate("Cannot be undone.", %{}, page_language) + } + rescue + _error -> %{target: "tag", count: 1, title: ShellData.translate("Merge Tags", %{}, page_language), message: ShellData.translate("Cannot be undone.", %{}, page_language)} + end + + defp overlay_kind("ai_suggestions"), do: :ai_suggestions + defp overlay_kind("insert_link"), do: :insert_link + defp overlay_kind("insert_media"), do: :insert_media + defp overlay_kind("language_picker"), do: :language_picker + defp overlay_kind("confirm_delete"), do: :confirm_delete + defp overlay_kind("confirm_merge"), do: :confirm_merge + defp overlay_kind("gallery"), do: :gallery + defp overlay_kind(_kind), do: nil + + defp overlay_tab("internal"), do: :internal + defp overlay_tab("external"), do: :external + defp overlay_tab(_tab), do: :internal + + defp update_shell_overlay(socket, updater) do + case socket.assigns[:shell_overlay] do + nil -> socket + overlay -> assign(socket, :shell_overlay, updater.(overlay)) + end + end + + defp close_overlay_with_output(socket, title, details) do + socket + |> append_output_entry(title, translated("Command completed"), details) + |> assign(:shell_overlay, nil) + end + + defp markdown_link(text, url), do: "[#{text}](#{url})" + + defp canonical_post_url(post) do + timestamp = post.published_at || post.updated_at || System.system_time(:millisecond) + date = DateTime.from_unix!(timestamp, :millisecond) + "/#{date.year}/#{pad2(date.month)}/#{pad2(date.day)}/#{post.slug || post.id}" + end + + defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0") + + defp refine_title(nil), do: "" + defp refine_title(title), do: String.trim(title <> " Notes") + + defp refine_excerpt(title, excerpt) do + base = excerpt |> to_string() |> String.trim() + if base == "", do: "#{title} overview", else: base <> "." + end + + defp refine_slug(slug), do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated") + + defp slugify(value) do + value + |> to_string() + |> String.downcase() + |> String.replace(~r/[^a-z0-9]+/u, "-") + |> String.trim("-") + end + defp media_thumbnail_glyph(mime_type) do case String.split(to_string(mime_type || ""), "/", parts: 2) do ["image", _rest] -> "IMG" diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex index c958450..e9758ff 100644 --- a/lib/bds/desktop/shell_live/index.html.heex +++ b/lib/bds/desktop/shell_live/index.html.heex @@ -370,11 +370,7 @@

<%= tab_title(@current_tab, @tab_meta) %>

<%= tab_subtitle(@current_tab, @tab_meta) %>

-
- - - -
+ <%= render_editor_toolbar(assigns) %>

<%= tab_title(@current_tab, @tab_meta) %>

@@ -627,4 +623,6 @@ <%= @status.right.brand %>
+ + <%= render_shell_overlay(assigns) %> diff --git a/priv/i18n/locales/de.json b/priv/i18n/locales/de.json index 936dfd6..fb0f71c 100644 --- a/priv/i18n/locales/de.json +++ b/priv/i18n/locales/de.json @@ -68,6 +68,9 @@ "%{count} posts": "%{count} Beiträge", "2 langs": "2 Sprachen", "AI Assistant": "KI-Assistent", + "AI Suggestions": "KI-Vorschlaege", + "Alt Text": "Alt-Text", + "Apply Selected": "Auswahl anwenden", "Across draft, published, and archive": "Über Entwürfe, veröffentlichte Beiträge und Archiv verteilt", "Activated %{name}": "%{name} aktiviert", "Archived": "Archiviert", @@ -77,6 +80,8 @@ "Automation can boot the shell in a separate process and capture screenshots": "Die Automatisierung kann die Shell in einem separaten Prozess starten und Screenshots aufnehmen", "Blog": "Blog", "Calendar regeneration is not wired yet, but the base shell now surfaces the command and keeps the Output tab selectable.": "Die Kalender-Neuerstellung ist noch nicht verdrahtet, aber die Basisshell zeigt den Befehl jetzt an und hält den Ausgabe-Tab auswählbar.", + "Cancel": "Abbrechen", + "Caption": "Bildunterschrift", "Chat": "Chat", "Close %{title}": "%{title} schließen", "Close tab": "Tab schließen", @@ -129,8 +134,14 @@ "Desktop workbench shell wired through Elixir": "Desktop-Workbench-Shell über Elixir verdrahtet", "Diff Reports": "Diff-Berichte", "Diffs": "Differenzen", + "Delete": "Loeschen", + "Delete Media": "Medium loeschen", + "Delete Tag": "Tag loeschen", + "Display Text": "Anzeigetext", "Documentation": "Dokumentation", "Drafts": "Entwürfe", + "Excerpt": "Auszug", + "External": "Extern", "Drafts, published entries, and archive history": "Entwürfe, veröffentlichte Einträge und Archivverlauf", "Edit": "Bearbeiten", "Extra": "Zusätzlich", @@ -139,12 +150,17 @@ "Filesystem Sync": "Dateisystem-Abgleich", "Fill Missing Translations": "Fehlende Übersetzungen ergänzen", "Find Duplicates": "Duplikate finden", + "Gallery": "Galerie", "Git": "Git", "Git Log": "Git-Protokoll", "Help": "Hilfe", "Idle": "Leerlauf", "Images and documents indexed": "Bilder und Dokumente indexiert", "Import": "Importieren", + "Insert": "Einfuegen", + "Insert Link": "Link einfuegen", + "Insert Media": "Medium einfuegen", + "Internal": "Intern", "Launch plan": "Startplan", "Main Language": "Hauptsprache", "Media": "Medien", @@ -199,6 +215,7 @@ "Source Control": "Quellcodeverwaltung", "Stale": "Veraltet", "Stale Pages": "Veraltete Seiten", + "Slug": "Slug", "Status": "Status", "Style": "Stil", "Switch project": "Projekt wechseln", @@ -206,15 +223,22 @@ "Tasks": "Aufgaben", "Template": "Vorlage", "Templates": "Vorlagen", + "Title": "Titel", "The app window is now served from the Elixir shell renderer.": "Das App-Fenster wird jetzt vom Elixir-Shell-Renderer ausgeliefert.", "The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.": "Das gemeinsame untere Panel steht für Aufgaben, Ausgabe, Git-Details und editorbezogene Diagnosen bereit.", "Toggle assistant": "Assistent umschalten", "Toggle offline mode": "Offline-Modus umschalten", "Toggle panel": "Panel umschalten", "Toggle sidebar": "Seitenleiste umschalten", + "Translate": "Uebersetzen", "Translation fill is not wired yet, but the command is now routed into Output instead of being ignored.": "Das Ergänzen fehlender Übersetzungen ist noch nicht verdrahtet, aber der Befehl wird jetzt in die Ausgabe geleitet statt ignoriert zu werden.", "Translations": "Übersetzungen", "UI": "UI", + "URL": "URL", + "Available languages": "Verfuegbare Sprachen", + "Cannot be undone.": "Dies kann nicht rueckgaengig gemacht werden.", + "Confirm": "Bestaetigen", + "This item is referenced by:": "Dieses Element wird referenziert von:", "Updated today": "Heute aktualisiert", "Updated yesterday": "Gestern aktualisiert", "Upload Site": "Website hochladen", diff --git a/priv/i18n/locales/en.json b/priv/i18n/locales/en.json index 92fa5bd..d9fd0bc 100644 --- a/priv/i18n/locales/en.json +++ b/priv/i18n/locales/en.json @@ -68,6 +68,9 @@ "%{count} posts": "%{count} posts", "2 langs": "2 langs", "AI Assistant": "AI Assistant", + "AI Suggestions": "AI Suggestions", + "Alt Text": "Alt Text", + "Apply Selected": "Apply Selected", "Across draft, published, and archive": "Across draft, published, and archive", "Activated %{name}": "Activated %{name}", "Archived": "Archived", @@ -77,6 +80,8 @@ "Automation can boot the shell in a separate process and capture screenshots": "Automation can boot the shell in a separate process and capture screenshots", "Blog": "Blog", "Calendar regeneration is not wired yet, but the base shell now surfaces the command and keeps the Output tab selectable.": "Calendar regeneration is not wired yet, but the base shell now surfaces the command and keeps the Output tab selectable.", + "Cancel": "Cancel", + "Caption": "Caption", "Chat": "Chat", "Close %{title}": "Close %{title}", "Close tab": "Close tab", @@ -129,8 +134,14 @@ "Desktop workbench shell wired through Elixir": "Desktop workbench shell wired through Elixir", "Diff Reports": "Diff Reports", "Diffs": "Diffs", + "Delete": "Delete", + "Delete Media": "Delete Media", + "Delete Tag": "Delete Tag", + "Display Text": "Display Text", "Documentation": "Documentation", "Drafts": "Drafts", + "Excerpt": "Excerpt", + "External": "External", "Drafts, published entries, and archive history": "Drafts, published entries, and archive history", "Edit": "Edit", "Extra": "Extra", @@ -139,12 +150,17 @@ "Filesystem Sync": "Filesystem Sync", "Fill Missing Translations": "Fill Missing Translations", "Find Duplicates": "Find Duplicates", + "Gallery": "Gallery", "Git": "Git", "Git Log": "Git Log", "Help": "Help", "Idle": "Idle", "Images and documents indexed": "Images and documents indexed", "Import": "Import", + "Insert": "Insert", + "Insert Link": "Insert Link", + "Insert Media": "Insert Media", + "Internal": "Internal", "Launch plan": "Launch plan", "Main Language": "Main Language", "Media": "Media", @@ -199,6 +215,7 @@ "Source Control": "Source Control", "Stale": "Stale", "Stale Pages": "Stale Pages", + "Slug": "Slug", "Status": "Status", "Style": "Style", "Switch project": "Switch project", @@ -206,15 +223,22 @@ "Tasks": "Tasks", "Template": "Template", "Templates": "Templates", + "Title": "Title", "The app window is now served from the Elixir shell renderer.": "The app window is now served from the Elixir shell renderer.", "The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.": "The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.", "Toggle assistant": "Toggle assistant", "Toggle offline mode": "Toggle offline mode", "Toggle panel": "Toggle panel", "Toggle sidebar": "Toggle sidebar", + "Translate": "Translate", "Translation fill is not wired yet, but the command is now routed into Output instead of being ignored.": "Translation fill is not wired yet, but the command is now routed into Output instead of being ignored.", "Translations": "Translations", "UI": "UI", + "URL": "URL", + "Available languages": "Available languages", + "Cannot be undone.": "Cannot be undone.", + "Confirm": "Confirm", + "This item is referenced by:": "This item is referenced by:", "Updated today": "Updated today", "Updated yesterday": "Updated yesterday", "Upload Site": "Upload Site", diff --git a/priv/i18n/locales/es.json b/priv/i18n/locales/es.json index a938187..c5665ed 100644 --- a/priv/i18n/locales/es.json +++ b/priv/i18n/locales/es.json @@ -68,6 +68,9 @@ "%{count} posts": "%{count} publicaciones", "2 langs": "2 idiomas", "AI Assistant": "Asistente de IA", + "AI Suggestions": "Sugerencias de IA", + "Alt Text": "Texto alternativo", + "Apply Selected": "Aplicar seleccionados", "Across draft, published, and archive": "Entre borradores, publicaciones y archivo", "Activated %{name}": "%{name} activado", "Archived": "Archivado", @@ -77,6 +80,8 @@ "Automation can boot the shell in a separate process and capture screenshots": "La automatización puede iniciar el shell en un proceso separado y capturar pantallas", "Blog": "Blog", "Calendar regeneration is not wired yet, but the base shell now surfaces the command and keeps the Output tab selectable.": "La regeneración del calendario aún no está conectada, pero el shell base ahora muestra el comando y mantiene seleccionable la pestaña Salida.", + "Cancel": "Cancelar", + "Caption": "Leyenda", "Chat": "Chat", "Close %{title}": "Cerrar %{title}", "Close tab": "Cerrar pestaña", @@ -129,8 +134,14 @@ "Desktop workbench shell wired through Elixir": "Shell del área de trabajo de escritorio conectado mediante Elixir", "Diff Reports": "Informes de diff", "Diffs": "Diferencias", + "Delete": "Eliminar", + "Delete Media": "Eliminar medio", + "Delete Tag": "Eliminar etiqueta", + "Display Text": "Texto mostrado", "Documentation": "Documentación", "Drafts": "Borradores", + "Excerpt": "Extracto", + "External": "Externo", "Drafts, published entries, and archive history": "Borradores, entradas publicadas e historial de archivo", "Edit": "Editar", "Extra": "Extra", @@ -139,12 +150,17 @@ "Filesystem Sync": "Sincronización del sistema de archivos", "Fill Missing Translations": "Completar traducciones faltantes", "Find Duplicates": "Buscar duplicados", + "Gallery": "Galeria", "Git": "Git", "Git Log": "Registro Git", "Help": "Ayuda", "Idle": "Inactivo", "Images and documents indexed": "Imágenes y documentos indexados", "Import": "Importar", + "Insert": "Insertar", + "Insert Link": "Insertar enlace", + "Insert Media": "Insertar medio", + "Internal": "Interno", "Launch plan": "Plan de lanzamiento", "Main Language": "Idioma principal", "Media": "Medios", @@ -199,6 +215,7 @@ "Source Control": "Control de código fuente", "Stale": "Desactualizado", "Stale Pages": "Páginas desactualizadas", + "Slug": "Slug", "Status": "Estado", "Style": "Estilo", "Switch project": "Cambiar proyecto", @@ -206,15 +223,22 @@ "Tasks": "Tareas", "Template": "Plantilla", "Templates": "Plantillas", + "Title": "Titulo", "The app window is now served from the Elixir shell renderer.": "La ventana de la aplicación ahora se sirve desde el renderizador shell de Elixir.", "The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.": "El panel inferior compartido está disponible para tareas, salida, detalles de Git y diagnósticos específicos del editor.", "Toggle assistant": "Alternar asistente", "Toggle offline mode": "Alternar modo sin conexión", "Toggle panel": "Alternar panel", "Toggle sidebar": "Alternar barra lateral", + "Translate": "Traducir", "Translation fill is not wired yet, but the command is now routed into Output instead of being ignored.": "El completado de traducciones aún no está conectado, pero el comando ahora se enruta a Salida en lugar de ignorarse.", "Translations": "Traducciones", "UI": "UI", + "URL": "URL", + "Available languages": "Idiomas disponibles", + "Cannot be undone.": "No se puede deshacer.", + "Confirm": "Confirmar", + "This item is referenced by:": "Este elemento esta referenciado por:", "Updated today": "Actualizado hoy", "Updated yesterday": "Actualizado ayer", "Upload Site": "Subir sitio", diff --git a/priv/i18n/locales/fr.json b/priv/i18n/locales/fr.json index ee53a7e..1dd153e 100644 --- a/priv/i18n/locales/fr.json +++ b/priv/i18n/locales/fr.json @@ -68,6 +68,9 @@ "%{count} posts": "%{count} articles", "2 langs": "2 langues", "AI Assistant": "Assistant IA", + "AI Suggestions": "Suggestions IA", + "Alt Text": "Texte alternatif", + "Apply Selected": "Appliquer la selection", "Across draft, published, and archive": "Répartis entre brouillons, publications et archives", "Activated %{name}": "%{name} activé", "Archived": "Archivé", @@ -77,6 +80,8 @@ "Automation can boot the shell in a separate process and capture screenshots": "L’automatisation peut démarrer le shell dans un processus séparé et capturer des captures d’écran", "Blog": "Blog", "Calendar regeneration is not wired yet, but the base shell now surfaces the command and keeps the Output tab selectable.": "La régénération du calendrier n’est pas encore câblée, mais le shell de base expose maintenant la commande et garde l’onglet Sortie sélectionnable.", + "Cancel": "Annuler", + "Caption": "Legende", "Chat": "Chat", "Close %{title}": "Fermer %{title}", "Close tab": "Fermer l’onglet", @@ -129,8 +134,14 @@ "Desktop workbench shell wired through Elixir": "Shell d’atelier bureau câblé via Elixir", "Diff Reports": "Rapports de diff", "Diffs": "Différences", + "Delete": "Supprimer", + "Delete Media": "Supprimer le media", + "Delete Tag": "Supprimer le tag", + "Display Text": "Texte affiche", "Documentation": "Documentation", "Drafts": "Brouillons", + "Excerpt": "Extrait", + "External": "Externe", "Drafts, published entries, and archive history": "Brouillons, éléments publiés et historique d’archives", "Edit": "Édition", "Extra": "Supplémentaire", @@ -139,12 +150,17 @@ "Filesystem Sync": "Synchronisation du système de fichiers", "Fill Missing Translations": "Compléter les traductions manquantes", "Find Duplicates": "Trouver les doublons", + "Gallery": "Galerie", "Git": "Git", "Git Log": "Journal Git", "Help": "Aide", "Idle": "Inactif", "Images and documents indexed": "Images et documents indexés", "Import": "Importer", + "Insert": "Inserer", + "Insert Link": "Inserer un lien", + "Insert Media": "Inserer un media", + "Internal": "Interne", "Launch plan": "Plan de lancement", "Main Language": "Langue principale", "Media": "Médias", @@ -199,6 +215,7 @@ "Source Control": "Contrôle de source", "Stale": "Obsolète", "Stale Pages": "Pages obsolètes", + "Slug": "Slug", "Status": "Statut", "Style": "Style", "Switch project": "Changer de projet", @@ -206,15 +223,22 @@ "Tasks": "Tâches", "Template": "Modèle", "Templates": "Modèles", + "Title": "Titre", "The app window is now served from the Elixir shell renderer.": "La fenêtre de l’application est maintenant servie par le moteur de rendu shell Elixir.", "The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.": "Le panneau inférieur partagé est disponible pour les tâches, la sortie, les détails Git et les diagnostics spécifiques à l’éditeur.", "Toggle assistant": "Afficher ou masquer l’assistant", "Toggle offline mode": "Basculer le mode hors ligne", "Toggle panel": "Afficher ou masquer le panneau", "Toggle sidebar": "Afficher ou masquer la barre latérale", + "Translate": "Traduire", "Translation fill is not wired yet, but the command is now routed into Output instead of being ignored.": "Le remplissage des traductions n’est pas encore câblé, mais la commande est maintenant envoyée vers Sortie au lieu d’être ignorée.", "Translations": "Traductions", "UI": "UI", + "URL": "URL", + "Available languages": "Langues disponibles", + "Cannot be undone.": "Cette action est irreversible.", + "Confirm": "Confirmer", + "This item is referenced by:": "Cet element est reference par :", "Updated today": "Mis à jour aujourd’hui", "Updated yesterday": "Mis à jour hier", "Upload Site": "Téléverser le site", diff --git a/priv/i18n/locales/it.json b/priv/i18n/locales/it.json index 352b865..ec1d92d 100644 --- a/priv/i18n/locales/it.json +++ b/priv/i18n/locales/it.json @@ -68,6 +68,9 @@ "%{count} posts": "%{count} post", "2 langs": "2 lingue", "AI Assistant": "Assistente IA", + "AI Suggestions": "Suggerimenti IA", + "Alt Text": "Testo alternativo", + "Apply Selected": "Applica selezionati", "Across draft, published, and archive": "Tra bozze, pubblicati e archivio", "Activated %{name}": "%{name} attivato", "Archived": "Archiviato", @@ -77,6 +80,8 @@ "Automation can boot the shell in a separate process and capture screenshots": "L’automazione può avviare la shell in un processo separato e catturare schermate", "Blog": "Blog", "Calendar regeneration is not wired yet, but the base shell now surfaces the command and keeps the Output tab selectable.": "La rigenerazione del calendario non è ancora collegata, ma la shell di base ora espone il comando e mantiene selezionabile la scheda Output.", + "Cancel": "Annulla", + "Caption": "Didascalia", "Chat": "Chat", "Close %{title}": "Chiudi %{title}", "Close tab": "Chiudi scheda", @@ -129,8 +134,14 @@ "Desktop workbench shell wired through Elixir": "Shell del banco di lavoro desktop collegata tramite Elixir", "Diff Reports": "Report diff", "Diffs": "Differenze", + "Delete": "Elimina", + "Delete Media": "Elimina media", + "Delete Tag": "Elimina tag", + "Display Text": "Testo visualizzato", "Documentation": "Documentazione", "Drafts": "Bozze", + "Excerpt": "Estratto", + "External": "Esterno", "Drafts, published entries, and archive history": "Bozze, elementi pubblicati e cronologia archivio", "Edit": "Modifica", "Extra": "Extra", @@ -139,12 +150,17 @@ "Filesystem Sync": "Sincronizzazione filesystem", "Fill Missing Translations": "Completa traduzioni mancanti", "Find Duplicates": "Trova duplicati", + "Gallery": "Galleria", "Git": "Git", "Git Log": "Log Git", "Help": "Aiuto", "Idle": "Inattivo", "Images and documents indexed": "Immagini e documenti indicizzati", "Import": "Importa", + "Insert": "Inserisci", + "Insert Link": "Inserisci collegamento", + "Insert Media": "Inserisci media", + "Internal": "Interno", "Launch plan": "Piano di lancio", "Main Language": "Lingua principale", "Media": "Media", @@ -199,6 +215,7 @@ "Source Control": "Controllo del codice sorgente", "Stale": "Obsoleto", "Stale Pages": "Pagine obsolete", + "Slug": "Slug", "Status": "Stato", "Style": "Stile", "Switch project": "Cambia progetto", @@ -206,15 +223,22 @@ "Tasks": "Attività", "Template": "Template", "Templates": "Template", + "Title": "Titolo", "The app window is now served from the Elixir shell renderer.": "La finestra dell’app è ora servita dal renderer shell Elixir.", "The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.": "Il pannello inferiore condiviso è disponibile per attività, output, dettagli Git e diagnostica specifica dell’editor.", "Toggle assistant": "Attiva/disattiva assistente", "Toggle offline mode": "Attiva/disattiva modalità offline", "Toggle panel": "Attiva/disattiva pannello", "Toggle sidebar": "Attiva/disattiva barra laterale", + "Translate": "Traduci", "Translation fill is not wired yet, but the command is now routed into Output instead of being ignored.": "Il completamento delle traduzioni non è ancora collegato, ma il comando ora viene instradato in Output invece di essere ignorato.", "Translations": "Traduzioni", "UI": "UI", + "URL": "URL", + "Available languages": "Lingue disponibili", + "Cannot be undone.": "Questa azione non puo essere annullata.", + "Confirm": "Conferma", + "This item is referenced by:": "Questo elemento e referenziato da:", "Updated today": "Aggiornato oggi", "Updated yesterday": "Aggiornato ieri", "Upload Site": "Carica sito", diff --git a/priv/ui/app.css b/priv/ui/app.css index 94154bb..d7d078a 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -847,6 +847,113 @@ button { display: none; } +.editor-toolbar-button.is-destructive { + color: #f48771; +} + +.shell-overlay-backdrop, +.gallery-overlay-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.68); + display: flex; + align-items: center; + justify-content: center; + pointer-events: auto; + z-index: 10000; +} + +.shell-overlay-dismiss { + position: absolute; + inset: 0; + border: none; + background: transparent; + padding: 0; +} + +.gallery-overlay { + position: relative; + width: min(980px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + display: flex; + flex-direction: column; + overflow: hidden; + background: #1e1e1e; + border: 1px solid #3c3c3c; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + z-index: 1; +} + +.insert-modal-media-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + padding: 16px; +} + +.insert-modal-media-item { + display: flex; + flex-direction: column; + gap: 8px; + border: 1px solid #3c3c3c; + border-radius: 8px; + background: #252526; + color: inherit; + padding: 10px; + text-align: left; +} + +.insert-modal-media-thumb { + width: 100%; + min-height: 112px; + border-radius: 6px; + object-fit: cover; + background: rgba(255, 255, 255, 0.04); +} + +.insert-modal-media-title { + font-weight: 600; + color: #ffffff; +} + +.language-picker-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.language-picker-option { + width: 100%; + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 12px; + align-items: center; + border: none; + border-radius: 4px; + padding: 12px 16px; + background: transparent; + color: inherit; + text-align: left; +} + +.language-picker-label, +.language-picker-status, +.lightbox-counter { + color: #9d9d9d; + font-size: 12px; +} + +.lightbox-counter { + margin-top: 4px; +} + +@media (max-width: 720px) { + .insert-modal-media-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + .panel-header { height: 35px; display: flex; diff --git a/test/bds/desktop/overlay_test.exs b/test/bds/desktop/overlay_test.exs new file mode 100644 index 0000000..f7ae1d9 --- /dev/null +++ b/test/bds/desktop/overlay_test.exs @@ -0,0 +1,105 @@ +defmodule BDS.Desktop.OverlayTest do + use ExUnit.Case, async: true + + alias BDS.Desktop.Overlay + + test "post overlays build picker, translation, and gallery payloads from shell context" do + context = sample_context() + + insert_link = Overlay.open(:post, :insert_link, context) + + assert insert_link.kind == :insert_link + assert insert_link.active_tab == :internal + assert Enum.map(insert_link.related_posts, & &1.post_id) == ["post-2", "post-3", "post-4"] + assert insert_link.results == [] + + insert_link = Overlay.set_search_query(insert_link, "pho") + + assert Enum.map(insert_link.results, & &1.post_id) == ["post-2"] + assert hd(insert_link.results).canonical_url == "/2026/04/26/photo-walk" + + language_picker = Overlay.open(:post, :language_picker, context) + + assert language_picker.kind == :language_picker + assert language_picker.source_language == "en" + assert Enum.map(language_picker.available_targets, & &1.code) == ["de", "fr"] + assert Enum.find(language_picker.available_targets, &(&1.code == "de")).has_existing_translation == true + + gallery = Overlay.open(:post, :gallery, context) + + assert gallery.kind == :gallery + assert gallery.post_id == "post-1" + assert Enum.map(gallery.images, & &1.media_id) == ["media-1", "media-2"] + assert gallery.lightbox == nil + + gallery = Overlay.select_gallery_image(gallery, "media-2") + + assert gallery.lightbox.media_id == "media-2" + assert gallery.lightbox.current_index == 1 + + gallery = Overlay.lightbox_next(gallery) + assert gallery.lightbox.media_id == "media-1" + + gallery = Overlay.lightbox_previous(gallery) + assert gallery.lightbox.media_id == "media-2" + end + + test "media and tag overlays keep shared AI, destructive, and confirm semantics" do + context = sample_context() + + ai_modal = Overlay.open(:media, :ai_suggestions, context) + + assert ai_modal.kind == :ai_suggestions + assert Enum.all?(ai_modal.fields, & &1.accepted) + + ai_modal = Overlay.toggle_ai_field(ai_modal, "caption") + refute Enum.find(ai_modal.fields, &(&1.key == "caption")).accepted + + delete_modal = Overlay.open(:media, :confirm_delete, context) + + assert delete_modal.kind == :confirm_delete + assert delete_modal.entity_type == "media" + assert delete_modal.reference_count == 2 + assert delete_modal.reference_list == ["Photo Walk", "Trip Notes"] + + confirm_dialog = Overlay.open(:tags, :confirm_merge, context) + + assert confirm_dialog.kind == :confirm_dialog + assert confirm_dialog.title == "Merge 3 tags into travel?" + assert confirm_dialog.message =~ "Cannot be undone" + end + + defp sample_context do + %{ + current_tab: %{type: :post, id: "post-1", title: "Trip Notes", subtitle: "Draft"}, + current_post_language: "en", + posts: [ + %{id: "post-1", title: "Trip Notes", status: "draft", canonical_url: "/2026/04/26/trip-notes"}, + %{id: "post-2", title: "Photo Walk", status: "published", canonical_url: "/2026/04/26/photo-walk"}, + %{id: "post-3", title: "Travel Checklist", status: "draft", canonical_url: "/2026/04/20/travel-checklist"}, + %{id: "post-4", title: "Packing List", status: "archived", canonical_url: "/2026/03/18/packing-list"} + ], + media: [ + %{id: "media-1", title: "Cover Shot", original_name: "cover-shot.jpg", is_image: true, thumbnail_url: "/media-thumbnail/media-1", image_url: "/media-thumbnail/media-1?size=large", alt_text: "Cover shot"}, + %{id: "media-2", title: "Street Scene", original_name: "street-scene.jpg", is_image: true, thumbnail_url: "/media-thumbnail/media-2", image_url: "/media-thumbnail/media-2?size=large", alt_text: "Street scene"}, + %{id: "media-3", title: "Audio Memo", original_name: "memo.m4a", is_image: false, thumbnail_url: nil, image_url: nil, alt_text: nil} + ], + post_media_ids: ["media-1", "media-2"], + blog_languages: ["en", "de", "fr"], + language_names: %{"en" => "English", "de" => "Deutsch", "fr" => "Francais"}, + language_flags: %{"en" => "GB", "de" => "DE", "fr" => "FR"}, + existing_translations: %{"de" => "draft"}, + ai_fields: [ + %{key: "title", label: "Title", current_value: "Street Scene", suggested_value: "Street Scene at Dusk", locked: false}, + %{key: "alt", label: "Alt Text", current_value: "", suggested_value: "Street scene at dusk", locked: false}, + %{key: "caption", label: "Caption", current_value: "Busy corner", suggested_value: "A busy corner at dusk", locked: false} + ], + delete_details: %{ + entity_name: "Street Scene", + entity_type: "media", + reference_list: ["Photo Walk", "Trip Notes"] + }, + merge_details: %{target: "travel", count: 3} + } + end +end diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index a321395..2cd97e2 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -270,6 +270,33 @@ defmodule BDS.UI.ShellTest do assert template =~ "assistant-sidebar-transcript" end + test "desktop shell assets expose the shared overlay render contract" do + css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") + 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") + + assert template =~ "render_editor_toolbar(assigns)" + assert template =~ "render_shell_overlay(assigns)" + + assert live_ex =~ ~s(def handle_event("open_overlay") + assert live_ex =~ ~s(def handle_event("close_overlay") + assert live_ex =~ ~s(def handle_event("overlay_keydown") + assert live_ex =~ "ai-suggestions-modal" + assert live_ex =~ "confirm-delete-modal" + assert live_ex =~ "insert-modal" + assert live_ex =~ "language-picker-modal" + assert live_ex =~ "gallery-overlay" + assert live_ex =~ "lightbox-overlay" + + assert css =~ ".shell-overlay-backdrop" + assert css =~ ".ai-suggestions-modal-backdrop" + assert css =~ ".confirm-delete-modal-backdrop" + assert css =~ ".insert-modal-backdrop" + assert css =~ ".language-picker-modal-backdrop" + assert css =~ ".gallery-overlay" + assert css =~ ".lightbox-overlay" + end + test "desktop shell css keeps the old assistant sidebar panel styling" do css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")