feat: plan step 4 done
This commit is contained in:
314
lib/bds/desktop/overlay.ex
Normal file
314
lib/bds/desktop/overlay.ex
Normal file
@@ -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
|
||||
@@ -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
|
||||
""
|
||||
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 %>
|
||||
<div class="editor-toolbar">
|
||||
<%= for button <- @editor_toolbar_buttons do %>
|
||||
<button
|
||||
class={["editor-toolbar-button", if(button.destructive, do: "is-destructive")]}
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind={button.kind}
|
||||
>
|
||||
<%= translated(button.label) %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% 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"""
|
||||
<div class="shell-overlay-backdrop ai-suggestions-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
||||
<div class="ai-suggestions-modal" role="dialog" aria-modal="true">
|
||||
<div class="ai-suggestions-modal-header">
|
||||
<h2><%= @shell_overlay.title %></h2>
|
||||
<button class="ai-suggestions-modal-close" type="button" phx-click="close_overlay">×</button>
|
||||
</div>
|
||||
<div class="ai-suggestions-modal-body">
|
||||
<div class="ai-suggestions-list">
|
||||
<%= for field <- @shell_overlay.fields do %>
|
||||
<div class="ai-suggestion-item">
|
||||
<label class="ai-suggestion-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.accepted}
|
||||
disabled={field.locked}
|
||||
phx-click="overlay_toggle_ai_field"
|
||||
phx-value-key={field.key}
|
||||
/>
|
||||
<span class="checkmark"></span>
|
||||
</label>
|
||||
<div class="ai-suggestion-content">
|
||||
<div class="ai-suggestion-label"><%= field.label %></div>
|
||||
<div class="ai-suggestion-current"><%= field.current_value %></div>
|
||||
<div class="ai-suggestion-value"><%= field.suggested_value %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-suggestions-modal-footer">
|
||||
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
|
||||
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= translated("Apply Selected") %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_insert_link_overlay(assigns) do
|
||||
~H"""
|
||||
<div class="shell-overlay-backdrop insert-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
||||
<div class="insert-modal" role="dialog" aria-modal="true">
|
||||
<div class="insert-modal-header">
|
||||
<h2 class="insert-modal-title"><%= @shell_overlay.title %></h2>
|
||||
<div class="insert-modal-tabs">
|
||||
<button class={["insert-modal-tab", if(@shell_overlay.active_tab == :internal, do: "active")]} type="button" phx-click="overlay_set_tab" phx-value-tab="internal"><%= translated("Internal") %></button>
|
||||
<button class={["insert-modal-tab", if(@shell_overlay.active_tab == :external, do: "active")]} type="button" phx-click="overlay_set_tab" phx-value-tab="external"><%= translated("External") %></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @shell_overlay.active_tab == :internal do %>
|
||||
<form class="insert-modal-search" phx-change="overlay_set_search">
|
||||
<input class="insert-modal-input" type="text" name="overlay[query]" value={@shell_overlay.search_query} placeholder={translated("sidebar.searchPostsPlaceholder")} />
|
||||
</form>
|
||||
<div class="insert-modal-results">
|
||||
<%= for result <- if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts) do %>
|
||||
<button class="insert-modal-result-item" type="button" phx-click="overlay_select_result" phx-value-id={result.post_id}>
|
||||
<div class="insert-modal-result-title"><%= result.title %></div>
|
||||
<div class="insert-modal-result-meta"><%= result.canonical_url %></div>
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if Enum.empty?(if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts)) do %>
|
||||
<div class="insert-modal-status"><%= translated("No items") %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<form class="insert-modal-external" phx-change="overlay_update_form">
|
||||
<label class="insert-modal-field">
|
||||
<span class="insert-modal-label"><%= translated("URL") %></span>
|
||||
<input class="insert-modal-input" type="text" name="overlay[url]" value={@shell_overlay.external_url} />
|
||||
</label>
|
||||
<label class="insert-modal-field">
|
||||
<span class="insert-modal-label"><%= translated("Display Text") %></span>
|
||||
<input class="insert-modal-input" type="text" name="overlay[text]" value={@shell_overlay.external_text} />
|
||||
</label>
|
||||
<button class="insert-modal-submit" type="button" phx-click="overlay_insert_external"><%= translated("Insert") %></button>
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_insert_media_overlay(assigns) do
|
||||
~H"""
|
||||
<div class="shell-overlay-backdrop insert-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
||||
<div class="insert-modal" role="dialog" aria-modal="true">
|
||||
<div class="insert-modal-header">
|
||||
<h2 class="insert-modal-title"><%= @shell_overlay.title %></h2>
|
||||
</div>
|
||||
<form class="insert-modal-search" phx-change="overlay_set_search">
|
||||
<input class="insert-modal-input" type="text" name="overlay[query]" value={@shell_overlay.search_query} placeholder={translated("sidebar.searchMediaPlaceholder")} />
|
||||
</form>
|
||||
<div class="insert-modal-results insert-modal-media-grid">
|
||||
<%= for result <- @shell_overlay.results do %>
|
||||
<button class="insert-modal-media-item" type="button" phx-click="overlay_select_result" phx-value-id={result.media_id}>
|
||||
<%= if result.thumbnail_url do %>
|
||||
<img class="insert-modal-media-thumb" src={result.thumbnail_url} alt="" loading="lazy" />
|
||||
<% else %>
|
||||
<span class="insert-modal-media-fallback"><%= result.original_name %></span>
|
||||
<% end %>
|
||||
<span class="insert-modal-media-title"><%= result.title %></span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_language_picker_overlay(assigns) do
|
||||
~H"""
|
||||
<div class="shell-overlay-backdrop language-picker-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
||||
<div class="language-picker-modal" role="dialog" aria-modal="true">
|
||||
<div class="language-picker-modal-header">
|
||||
<h2><%= @shell_overlay.title %></h2>
|
||||
<button class="language-picker-modal-close" type="button" phx-click="close_overlay">×</button>
|
||||
</div>
|
||||
<div class="language-picker-modal-body">
|
||||
<div class="language-picker-label"><%= translated("Available languages") %></div>
|
||||
<div class="language-picker-options">
|
||||
<%= for target <- @shell_overlay.available_targets do %>
|
||||
<button class="language-picker-option" type="button" phx-click="overlay_select_language" phx-value-code={target.code}>
|
||||
<span class="language-picker-flag"><%= target.flag_emoji %></span>
|
||||
<span class="language-picker-name"><%= target.name %></span>
|
||||
<%= if target.has_existing_translation do %>
|
||||
<span class="language-picker-status"><%= target.existing_status %></span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_confirm_delete_overlay(assigns) do
|
||||
~H"""
|
||||
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
||||
<div class="confirm-delete-modal" role="dialog" aria-modal="true">
|
||||
<div class="confirm-delete-modal-header">
|
||||
<h2><%= @shell_overlay.title %></h2>
|
||||
<button class="confirm-delete-modal-close" type="button" phx-click="close_overlay">×</button>
|
||||
</div>
|
||||
<div class="confirm-delete-modal-body">
|
||||
<div class="confirm-delete-message"><strong><%= @shell_overlay.entity_name %></strong></div>
|
||||
<%= if @shell_overlay.reference_count > 0 do %>
|
||||
<div class="confirm-delete-warning">
|
||||
<div class="warning-content">
|
||||
<strong><%= translated("This item is referenced by:") %></strong>
|
||||
<ul class="reference-list">
|
||||
<%= for title <- @shell_overlay.reference_list do %>
|
||||
<li><span class="reference-title"><%= title %></span></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="confirm-delete-modal-footer">
|
||||
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
|
||||
<button class="button-delete" type="button" phx-click="overlay_confirm"><%= translated("Delete") %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_confirm_dialog_overlay(assigns) do
|
||||
~H"""
|
||||
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
||||
<div class="confirm-delete-modal" role="dialog" aria-modal="true">
|
||||
<div class="confirm-delete-modal-header">
|
||||
<h2><%= @shell_overlay.title %></h2>
|
||||
<button class="confirm-delete-modal-close" type="button" phx-click="close_overlay">×</button>
|
||||
</div>
|
||||
<div class="confirm-delete-modal-body">
|
||||
<div class="confirm-delete-message"><%= @shell_overlay.message %></div>
|
||||
</div>
|
||||
<div class="confirm-delete-modal-footer">
|
||||
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
|
||||
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= translated("Confirm") %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_gallery_overlay(assigns) do
|
||||
~H"""
|
||||
<div class="shell-overlay-backdrop gallery-overlay-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
||||
<div class="gallery-overlay" role="dialog" aria-modal="true">
|
||||
<div class="gallery-overlay-header">
|
||||
<h2><%= translated("Gallery") %></h2>
|
||||
<button class="gallery-overlay-close" type="button" phx-click="close_overlay">×</button>
|
||||
</div>
|
||||
<div class="gallery-overlay-grid">
|
||||
<%= for image <- @shell_overlay.images do %>
|
||||
<button class="gallery-overlay-item" type="button" phx-click="overlay_select_gallery_image" phx-value-id={image.media_id}>
|
||||
<img src={image.thumbnail_url} alt={image.alt_text || ""} loading="lazy" />
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @shell_overlay.lightbox do %>
|
||||
<div class="lightbox-overlay">
|
||||
<button class="shell-overlay-dismiss" type="button" phx-click="overlay_close_lightbox" aria-label={translated("Cancel")}></button>
|
||||
<div class="lightbox-container">
|
||||
<button class="lightbox-close" type="button" phx-click="overlay_close_lightbox">×</button>
|
||||
<%= if @shell_overlay.lightbox.total_count > 1 do %>
|
||||
<button class="lightbox-nav lightbox-prev" type="button" phx-click="overlay_lightbox_previous">‹</button>
|
||||
<button class="lightbox-nav lightbox-next" type="button" phx-click="overlay_lightbox_next">›</button>
|
||||
<% end %>
|
||||
<div class="lightbox-image-container">
|
||||
<img class="lightbox-image" src={@shell_overlay.lightbox.image_url} alt={@shell_overlay.lightbox.alt_text || ""} />
|
||||
</div>
|
||||
<div class="lightbox-footer">
|
||||
<div class="lightbox-caption"><%= @shell_overlay.lightbox.title %></div>
|
||||
<div class="lightbox-counter"><%= @shell_overlay.lightbox.current_index + 1 %> / <%= @shell_overlay.lightbox.total_count %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
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"
|
||||
|
||||
@@ -370,11 +370,7 @@
|
||||
<h1 class="editor-title" data-testid="editor-title"><%= tab_title(@current_tab, @tab_meta) %></h1>
|
||||
<p class="editor-subtitle"><%= tab_subtitle(@current_tab, @tab_meta) %></p>
|
||||
|
||||
<div class="editor-toolbar">
|
||||
<button class="editor-toolbar-button" type="button">Open</button>
|
||||
<button class="editor-toolbar-button" type="button">Preview</button>
|
||||
<button class="editor-toolbar-button" type="button">Metadata</button>
|
||||
</div>
|
||||
<%= render_editor_toolbar(assigns) %>
|
||||
|
||||
<div class="editor-section">
|
||||
<h2><%= tab_title(@current_tab, @tab_meta) %></h2>
|
||||
@@ -627,4 +623,6 @@
|
||||
<span class="status-bar-item brand"><%= @status.right.brand %></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<%= render_shell_overlay(assigns) %>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
107
priv/ui/app.css
107
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;
|
||||
|
||||
105
test/bds/desktop/overlay_test.exs
Normal file
105
test/bds/desktop/overlay_test.exs
Normal file
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user