feat: plan step 4 done

This commit is contained in:
2026-04-26 13:44:31 +02:00
parent 8e8d03fcb0
commit 3c91a30769
11 changed files with 1428 additions and 8 deletions

314
lib/bds/desktop/overlay.ex Normal file
View 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

View File

@@ -3,16 +3,18 @@ defmodule BDS.Desktop.ShellLive do
use Phoenix.LiveView use Phoenix.LiveView
import Ecto.Query
import Phoenix.HTML import Phoenix.HTML
alias BDS.Desktop.{FolderPicker, ShellCommands, ShellData} alias BDS.Desktop.{FolderPicker, Overlay, ShellCommands, ShellData}
alias BDS.Desktop.MenuBar, as: DesktopMenuBar alias BDS.Desktop.MenuBar, as: DesktopMenuBar
alias BDS.Git alias BDS.{Git, I18n, Metadata}
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.PostLinks alias BDS.PostLinks
alias BDS.Posts.Post alias BDS.Posts.{Post, Translation}
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
alias BDS.Tags.Tag
alias BDS.UI.{Commands, MenuBar, Registry, Session, Workbench} alias BDS.UI.{Commands, MenuBar, Registry, Session, Workbench}
@refresh_interval 1_500 @refresh_interval 1_500
@@ -57,6 +59,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:project_menu_open, false) |> assign(:project_menu_open, false)
|> assign(:sidebar_filters_by_view, %{}) |> assign(:sidebar_filters_by_view, %{})
|> assign(:sidebar_filter_panels, %{}) |> assign(:sidebar_filter_panels, %{})
|> assign(:shell_overlay, nil)
|> assign(:output_entries, []) |> assign(:output_entries, [])
|> reload_shell(workbench)} |> reload_shell(workbench)}
end end
@@ -306,6 +309,157 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, reload_shell(socket, workbench)} {:noreply, reload_shell(socket, workbench)}
end 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 def handle_event("toggle_project_menu", _params, socket) do
{:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)} {:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)}
end end
@@ -864,6 +1018,286 @@ defmodule BDS.Desktop.ShellLive do
end end
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 defp render_task_entries(assigns) do
~H""" ~H"""
<%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %> <%= 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(nil), do: translated("Dashboard")
defp tab_route_label(%{type: type}), do: ShellData.route_label(type) 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(nil), do: "posts"
defp tab_icon_id(%{type: :post}), do: "posts" defp tab_icon_id(%{type: :post}), do: "posts"
defp tab_icon_id(%{type: :git_diff}), do: "git" 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 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 defp media_thumbnail_glyph(mime_type) do
case String.split(to_string(mime_type || ""), "/", parts: 2) do case String.split(to_string(mime_type || ""), "/", parts: 2) do
["image", _rest] -> "IMG" ["image", _rest] -> "IMG"

View File

@@ -370,11 +370,7 @@
<h1 class="editor-title" data-testid="editor-title"><%= tab_title(@current_tab, @tab_meta) %></h1> <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> <p class="editor-subtitle"><%= tab_subtitle(@current_tab, @tab_meta) %></p>
<div class="editor-toolbar"> <%= render_editor_toolbar(assigns) %>
<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>
<div class="editor-section"> <div class="editor-section">
<h2><%= tab_title(@current_tab, @tab_meta) %></h2> <h2><%= tab_title(@current_tab, @tab_meta) %></h2>
@@ -627,4 +623,6 @@
<span class="status-bar-item brand"><%= @status.right.brand %></span> <span class="status-bar-item brand"><%= @status.right.brand %></span>
</div> </div>
</footer> </footer>
<%= render_shell_overlay(assigns) %>
</div> </div>

View File

@@ -68,6 +68,9 @@
"%{count} posts": "%{count} Beiträge", "%{count} posts": "%{count} Beiträge",
"2 langs": "2 Sprachen", "2 langs": "2 Sprachen",
"AI Assistant": "KI-Assistent", "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", "Across draft, published, and archive": "Über Entwürfe, veröffentlichte Beiträge und Archiv verteilt",
"Activated %{name}": "%{name} aktiviert", "Activated %{name}": "%{name} aktiviert",
"Archived": "Archiviert", "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", "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", "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.", "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", "Chat": "Chat",
"Close %{title}": "%{title} schließen", "Close %{title}": "%{title} schließen",
"Close tab": "Tab schließen", "Close tab": "Tab schließen",
@@ -129,8 +134,14 @@
"Desktop workbench shell wired through Elixir": "Desktop-Workbench-Shell über Elixir verdrahtet", "Desktop workbench shell wired through Elixir": "Desktop-Workbench-Shell über Elixir verdrahtet",
"Diff Reports": "Diff-Berichte", "Diff Reports": "Diff-Berichte",
"Diffs": "Differenzen", "Diffs": "Differenzen",
"Delete": "Loeschen",
"Delete Media": "Medium loeschen",
"Delete Tag": "Tag loeschen",
"Display Text": "Anzeigetext",
"Documentation": "Dokumentation", "Documentation": "Dokumentation",
"Drafts": "Entwürfe", "Drafts": "Entwürfe",
"Excerpt": "Auszug",
"External": "Extern",
"Drafts, published entries, and archive history": "Entwürfe, veröffentlichte Einträge und Archivverlauf", "Drafts, published entries, and archive history": "Entwürfe, veröffentlichte Einträge und Archivverlauf",
"Edit": "Bearbeiten", "Edit": "Bearbeiten",
"Extra": "Zusätzlich", "Extra": "Zusätzlich",
@@ -139,12 +150,17 @@
"Filesystem Sync": "Dateisystem-Abgleich", "Filesystem Sync": "Dateisystem-Abgleich",
"Fill Missing Translations": "Fehlende Übersetzungen ergänzen", "Fill Missing Translations": "Fehlende Übersetzungen ergänzen",
"Find Duplicates": "Duplikate finden", "Find Duplicates": "Duplikate finden",
"Gallery": "Galerie",
"Git": "Git", "Git": "Git",
"Git Log": "Git-Protokoll", "Git Log": "Git-Protokoll",
"Help": "Hilfe", "Help": "Hilfe",
"Idle": "Leerlauf", "Idle": "Leerlauf",
"Images and documents indexed": "Bilder und Dokumente indexiert", "Images and documents indexed": "Bilder und Dokumente indexiert",
"Import": "Importieren", "Import": "Importieren",
"Insert": "Einfuegen",
"Insert Link": "Link einfuegen",
"Insert Media": "Medium einfuegen",
"Internal": "Intern",
"Launch plan": "Startplan", "Launch plan": "Startplan",
"Main Language": "Hauptsprache", "Main Language": "Hauptsprache",
"Media": "Medien", "Media": "Medien",
@@ -199,6 +215,7 @@
"Source Control": "Quellcodeverwaltung", "Source Control": "Quellcodeverwaltung",
"Stale": "Veraltet", "Stale": "Veraltet",
"Stale Pages": "Veraltete Seiten", "Stale Pages": "Veraltete Seiten",
"Slug": "Slug",
"Status": "Status", "Status": "Status",
"Style": "Stil", "Style": "Stil",
"Switch project": "Projekt wechseln", "Switch project": "Projekt wechseln",
@@ -206,15 +223,22 @@
"Tasks": "Aufgaben", "Tasks": "Aufgaben",
"Template": "Vorlage", "Template": "Vorlage",
"Templates": "Vorlagen", "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 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.", "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 assistant": "Assistent umschalten",
"Toggle offline mode": "Offline-Modus umschalten", "Toggle offline mode": "Offline-Modus umschalten",
"Toggle panel": "Panel umschalten", "Toggle panel": "Panel umschalten",
"Toggle sidebar": "Seitenleiste 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.", "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", "Translations": "Übersetzungen",
"UI": "UI", "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 today": "Heute aktualisiert",
"Updated yesterday": "Gestern aktualisiert", "Updated yesterday": "Gestern aktualisiert",
"Upload Site": "Website hochladen", "Upload Site": "Website hochladen",

View File

@@ -68,6 +68,9 @@
"%{count} posts": "%{count} posts", "%{count} posts": "%{count} posts",
"2 langs": "2 langs", "2 langs": "2 langs",
"AI Assistant": "AI Assistant", "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", "Across draft, published, and archive": "Across draft, published, and archive",
"Activated %{name}": "Activated %{name}", "Activated %{name}": "Activated %{name}",
"Archived": "Archived", "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", "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", "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.", "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", "Chat": "Chat",
"Close %{title}": "Close %{title}", "Close %{title}": "Close %{title}",
"Close tab": "Close tab", "Close tab": "Close tab",
@@ -129,8 +134,14 @@
"Desktop workbench shell wired through Elixir": "Desktop workbench shell wired through Elixir", "Desktop workbench shell wired through Elixir": "Desktop workbench shell wired through Elixir",
"Diff Reports": "Diff Reports", "Diff Reports": "Diff Reports",
"Diffs": "Diffs", "Diffs": "Diffs",
"Delete": "Delete",
"Delete Media": "Delete Media",
"Delete Tag": "Delete Tag",
"Display Text": "Display Text",
"Documentation": "Documentation", "Documentation": "Documentation",
"Drafts": "Drafts", "Drafts": "Drafts",
"Excerpt": "Excerpt",
"External": "External",
"Drafts, published entries, and archive history": "Drafts, published entries, and archive history", "Drafts, published entries, and archive history": "Drafts, published entries, and archive history",
"Edit": "Edit", "Edit": "Edit",
"Extra": "Extra", "Extra": "Extra",
@@ -139,12 +150,17 @@
"Filesystem Sync": "Filesystem Sync", "Filesystem Sync": "Filesystem Sync",
"Fill Missing Translations": "Fill Missing Translations", "Fill Missing Translations": "Fill Missing Translations",
"Find Duplicates": "Find Duplicates", "Find Duplicates": "Find Duplicates",
"Gallery": "Gallery",
"Git": "Git", "Git": "Git",
"Git Log": "Git Log", "Git Log": "Git Log",
"Help": "Help", "Help": "Help",
"Idle": "Idle", "Idle": "Idle",
"Images and documents indexed": "Images and documents indexed", "Images and documents indexed": "Images and documents indexed",
"Import": "Import", "Import": "Import",
"Insert": "Insert",
"Insert Link": "Insert Link",
"Insert Media": "Insert Media",
"Internal": "Internal",
"Launch plan": "Launch plan", "Launch plan": "Launch plan",
"Main Language": "Main Language", "Main Language": "Main Language",
"Media": "Media", "Media": "Media",
@@ -199,6 +215,7 @@
"Source Control": "Source Control", "Source Control": "Source Control",
"Stale": "Stale", "Stale": "Stale",
"Stale Pages": "Stale Pages", "Stale Pages": "Stale Pages",
"Slug": "Slug",
"Status": "Status", "Status": "Status",
"Style": "Style", "Style": "Style",
"Switch project": "Switch project", "Switch project": "Switch project",
@@ -206,15 +223,22 @@
"Tasks": "Tasks", "Tasks": "Tasks",
"Template": "Template", "Template": "Template",
"Templates": "Templates", "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 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.", "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 assistant": "Toggle assistant",
"Toggle offline mode": "Toggle offline mode", "Toggle offline mode": "Toggle offline mode",
"Toggle panel": "Toggle panel", "Toggle panel": "Toggle panel",
"Toggle sidebar": "Toggle sidebar", "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.", "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", "Translations": "Translations",
"UI": "UI", "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 today": "Updated today",
"Updated yesterday": "Updated yesterday", "Updated yesterday": "Updated yesterday",
"Upload Site": "Upload Site", "Upload Site": "Upload Site",

View File

@@ -68,6 +68,9 @@
"%{count} posts": "%{count} publicaciones", "%{count} posts": "%{count} publicaciones",
"2 langs": "2 idiomas", "2 langs": "2 idiomas",
"AI Assistant": "Asistente de IA", "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", "Across draft, published, and archive": "Entre borradores, publicaciones y archivo",
"Activated %{name}": "%{name} activado", "Activated %{name}": "%{name} activado",
"Archived": "Archivado", "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", "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", "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.", "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", "Chat": "Chat",
"Close %{title}": "Cerrar %{title}", "Close %{title}": "Cerrar %{title}",
"Close tab": "Cerrar pestaña", "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", "Desktop workbench shell wired through Elixir": "Shell del área de trabajo de escritorio conectado mediante Elixir",
"Diff Reports": "Informes de diff", "Diff Reports": "Informes de diff",
"Diffs": "Diferencias", "Diffs": "Diferencias",
"Delete": "Eliminar",
"Delete Media": "Eliminar medio",
"Delete Tag": "Eliminar etiqueta",
"Display Text": "Texto mostrado",
"Documentation": "Documentación", "Documentation": "Documentación",
"Drafts": "Borradores", "Drafts": "Borradores",
"Excerpt": "Extracto",
"External": "Externo",
"Drafts, published entries, and archive history": "Borradores, entradas publicadas e historial de archivo", "Drafts, published entries, and archive history": "Borradores, entradas publicadas e historial de archivo",
"Edit": "Editar", "Edit": "Editar",
"Extra": "Extra", "Extra": "Extra",
@@ -139,12 +150,17 @@
"Filesystem Sync": "Sincronización del sistema de archivos", "Filesystem Sync": "Sincronización del sistema de archivos",
"Fill Missing Translations": "Completar traducciones faltantes", "Fill Missing Translations": "Completar traducciones faltantes",
"Find Duplicates": "Buscar duplicados", "Find Duplicates": "Buscar duplicados",
"Gallery": "Galeria",
"Git": "Git", "Git": "Git",
"Git Log": "Registro Git", "Git Log": "Registro Git",
"Help": "Ayuda", "Help": "Ayuda",
"Idle": "Inactivo", "Idle": "Inactivo",
"Images and documents indexed": "Imágenes y documentos indexados", "Images and documents indexed": "Imágenes y documentos indexados",
"Import": "Importar", "Import": "Importar",
"Insert": "Insertar",
"Insert Link": "Insertar enlace",
"Insert Media": "Insertar medio",
"Internal": "Interno",
"Launch plan": "Plan de lanzamiento", "Launch plan": "Plan de lanzamiento",
"Main Language": "Idioma principal", "Main Language": "Idioma principal",
"Media": "Medios", "Media": "Medios",
@@ -199,6 +215,7 @@
"Source Control": "Control de código fuente", "Source Control": "Control de código fuente",
"Stale": "Desactualizado", "Stale": "Desactualizado",
"Stale Pages": "Páginas desactualizadas", "Stale Pages": "Páginas desactualizadas",
"Slug": "Slug",
"Status": "Estado", "Status": "Estado",
"Style": "Estilo", "Style": "Estilo",
"Switch project": "Cambiar proyecto", "Switch project": "Cambiar proyecto",
@@ -206,15 +223,22 @@
"Tasks": "Tareas", "Tasks": "Tareas",
"Template": "Plantilla", "Template": "Plantilla",
"Templates": "Plantillas", "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 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.", "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 assistant": "Alternar asistente",
"Toggle offline mode": "Alternar modo sin conexión", "Toggle offline mode": "Alternar modo sin conexión",
"Toggle panel": "Alternar panel", "Toggle panel": "Alternar panel",
"Toggle sidebar": "Alternar barra lateral", "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.", "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", "Translations": "Traducciones",
"UI": "UI", "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 today": "Actualizado hoy",
"Updated yesterday": "Actualizado ayer", "Updated yesterday": "Actualizado ayer",
"Upload Site": "Subir sitio", "Upload Site": "Subir sitio",

View File

@@ -68,6 +68,9 @@
"%{count} posts": "%{count} articles", "%{count} posts": "%{count} articles",
"2 langs": "2 langues", "2 langs": "2 langues",
"AI Assistant": "Assistant IA", "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", "Across draft, published, and archive": "Répartis entre brouillons, publications et archives",
"Activated %{name}": "%{name} activé", "Activated %{name}": "%{name} activé",
"Archived": "Archivé", "Archived": "Archivé",
@@ -77,6 +80,8 @@
"Automation can boot the shell in a separate process and capture screenshots": "Lautomatisation peut démarrer le shell dans un processus séparé et capturer des captures décran", "Automation can boot the shell in a separate process and capture screenshots": "Lautomatisation peut démarrer le shell dans un processus séparé et capturer des captures décran",
"Blog": "Blog", "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 nest pas encore câblée, mais le shell de base expose maintenant la commande et garde longlet Sortie sélectionnable.", "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 nest pas encore câblée, mais le shell de base expose maintenant la commande et garde longlet Sortie sélectionnable.",
"Cancel": "Annuler",
"Caption": "Legende",
"Chat": "Chat", "Chat": "Chat",
"Close %{title}": "Fermer %{title}", "Close %{title}": "Fermer %{title}",
"Close tab": "Fermer longlet", "Close tab": "Fermer longlet",
@@ -129,8 +134,14 @@
"Desktop workbench shell wired through Elixir": "Shell datelier bureau câblé via Elixir", "Desktop workbench shell wired through Elixir": "Shell datelier bureau câblé via Elixir",
"Diff Reports": "Rapports de diff", "Diff Reports": "Rapports de diff",
"Diffs": "Différences", "Diffs": "Différences",
"Delete": "Supprimer",
"Delete Media": "Supprimer le media",
"Delete Tag": "Supprimer le tag",
"Display Text": "Texte affiche",
"Documentation": "Documentation", "Documentation": "Documentation",
"Drafts": "Brouillons", "Drafts": "Brouillons",
"Excerpt": "Extrait",
"External": "Externe",
"Drafts, published entries, and archive history": "Brouillons, éléments publiés et historique darchives", "Drafts, published entries, and archive history": "Brouillons, éléments publiés et historique darchives",
"Edit": "Édition", "Edit": "Édition",
"Extra": "Supplémentaire", "Extra": "Supplémentaire",
@@ -139,12 +150,17 @@
"Filesystem Sync": "Synchronisation du système de fichiers", "Filesystem Sync": "Synchronisation du système de fichiers",
"Fill Missing Translations": "Compléter les traductions manquantes", "Fill Missing Translations": "Compléter les traductions manquantes",
"Find Duplicates": "Trouver les doublons", "Find Duplicates": "Trouver les doublons",
"Gallery": "Galerie",
"Git": "Git", "Git": "Git",
"Git Log": "Journal Git", "Git Log": "Journal Git",
"Help": "Aide", "Help": "Aide",
"Idle": "Inactif", "Idle": "Inactif",
"Images and documents indexed": "Images et documents indexés", "Images and documents indexed": "Images et documents indexés",
"Import": "Importer", "Import": "Importer",
"Insert": "Inserer",
"Insert Link": "Inserer un lien",
"Insert Media": "Inserer un media",
"Internal": "Interne",
"Launch plan": "Plan de lancement", "Launch plan": "Plan de lancement",
"Main Language": "Langue principale", "Main Language": "Langue principale",
"Media": "Médias", "Media": "Médias",
@@ -199,6 +215,7 @@
"Source Control": "Contrôle de source", "Source Control": "Contrôle de source",
"Stale": "Obsolète", "Stale": "Obsolète",
"Stale Pages": "Pages obsolètes", "Stale Pages": "Pages obsolètes",
"Slug": "Slug",
"Status": "Statut", "Status": "Statut",
"Style": "Style", "Style": "Style",
"Switch project": "Changer de projet", "Switch project": "Changer de projet",
@@ -206,15 +223,22 @@
"Tasks": "Tâches", "Tasks": "Tâches",
"Template": "Modèle", "Template": "Modèle",
"Templates": "Modèles", "Templates": "Modèles",
"Title": "Titre",
"The app window is now served from the Elixir shell renderer.": "La fenêtre de lapplication est maintenant servie par le moteur de rendu shell Elixir.", "The app window is now served from the Elixir shell renderer.": "La fenêtre de lapplication 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.", "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 lassistant", "Toggle assistant": "Afficher ou masquer lassistant",
"Toggle offline mode": "Basculer le mode hors ligne", "Toggle offline mode": "Basculer le mode hors ligne",
"Toggle panel": "Afficher ou masquer le panneau", "Toggle panel": "Afficher ou masquer le panneau",
"Toggle sidebar": "Afficher ou masquer la barre latérale", "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 nest pas encore câblé, mais la commande est maintenant envoyée vers Sortie au lieu dêtre ignorée.", "Translation fill is not wired yet, but the command is now routed into Output instead of being ignored.": "Le remplissage des traductions nest pas encore câblé, mais la commande est maintenant envoyée vers Sortie au lieu dêtre ignorée.",
"Translations": "Traductions", "Translations": "Traductions",
"UI": "UI", "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 aujourdhui", "Updated today": "Mis à jour aujourdhui",
"Updated yesterday": "Mis à jour hier", "Updated yesterday": "Mis à jour hier",
"Upload Site": "Téléverser le site", "Upload Site": "Téléverser le site",

View File

@@ -68,6 +68,9 @@
"%{count} posts": "%{count} post", "%{count} posts": "%{count} post",
"2 langs": "2 lingue", "2 langs": "2 lingue",
"AI Assistant": "Assistente IA", "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", "Across draft, published, and archive": "Tra bozze, pubblicati e archivio",
"Activated %{name}": "%{name} attivato", "Activated %{name}": "%{name} attivato",
"Archived": "Archiviato", "Archived": "Archiviato",
@@ -77,6 +80,8 @@
"Automation can boot the shell in a separate process and capture screenshots": "Lautomazione può avviare la shell in un processo separato e catturare schermate", "Automation can boot the shell in a separate process and capture screenshots": "Lautomazione può avviare la shell in un processo separato e catturare schermate",
"Blog": "Blog", "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.", "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", "Chat": "Chat",
"Close %{title}": "Chiudi %{title}", "Close %{title}": "Chiudi %{title}",
"Close tab": "Chiudi scheda", "Close tab": "Chiudi scheda",
@@ -129,8 +134,14 @@
"Desktop workbench shell wired through Elixir": "Shell del banco di lavoro desktop collegata tramite Elixir", "Desktop workbench shell wired through Elixir": "Shell del banco di lavoro desktop collegata tramite Elixir",
"Diff Reports": "Report diff", "Diff Reports": "Report diff",
"Diffs": "Differenze", "Diffs": "Differenze",
"Delete": "Elimina",
"Delete Media": "Elimina media",
"Delete Tag": "Elimina tag",
"Display Text": "Testo visualizzato",
"Documentation": "Documentazione", "Documentation": "Documentazione",
"Drafts": "Bozze", "Drafts": "Bozze",
"Excerpt": "Estratto",
"External": "Esterno",
"Drafts, published entries, and archive history": "Bozze, elementi pubblicati e cronologia archivio", "Drafts, published entries, and archive history": "Bozze, elementi pubblicati e cronologia archivio",
"Edit": "Modifica", "Edit": "Modifica",
"Extra": "Extra", "Extra": "Extra",
@@ -139,12 +150,17 @@
"Filesystem Sync": "Sincronizzazione filesystem", "Filesystem Sync": "Sincronizzazione filesystem",
"Fill Missing Translations": "Completa traduzioni mancanti", "Fill Missing Translations": "Completa traduzioni mancanti",
"Find Duplicates": "Trova duplicati", "Find Duplicates": "Trova duplicati",
"Gallery": "Galleria",
"Git": "Git", "Git": "Git",
"Git Log": "Log Git", "Git Log": "Log Git",
"Help": "Aiuto", "Help": "Aiuto",
"Idle": "Inattivo", "Idle": "Inattivo",
"Images and documents indexed": "Immagini e documenti indicizzati", "Images and documents indexed": "Immagini e documenti indicizzati",
"Import": "Importa", "Import": "Importa",
"Insert": "Inserisci",
"Insert Link": "Inserisci collegamento",
"Insert Media": "Inserisci media",
"Internal": "Interno",
"Launch plan": "Piano di lancio", "Launch plan": "Piano di lancio",
"Main Language": "Lingua principale", "Main Language": "Lingua principale",
"Media": "Media", "Media": "Media",
@@ -199,6 +215,7 @@
"Source Control": "Controllo del codice sorgente", "Source Control": "Controllo del codice sorgente",
"Stale": "Obsoleto", "Stale": "Obsoleto",
"Stale Pages": "Pagine obsolete", "Stale Pages": "Pagine obsolete",
"Slug": "Slug",
"Status": "Stato", "Status": "Stato",
"Style": "Stile", "Style": "Stile",
"Switch project": "Cambia progetto", "Switch project": "Cambia progetto",
@@ -206,15 +223,22 @@
"Tasks": "Attività", "Tasks": "Attività",
"Template": "Template", "Template": "Template",
"Templates": "Template", "Templates": "Template",
"Title": "Titolo",
"The app window is now served from the Elixir shell renderer.": "La finestra dellapp è ora servita dal renderer shell Elixir.", "The app window is now served from the Elixir shell renderer.": "La finestra dellapp è 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 delleditor.", "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 delleditor.",
"Toggle assistant": "Attiva/disattiva assistente", "Toggle assistant": "Attiva/disattiva assistente",
"Toggle offline mode": "Attiva/disattiva modalità offline", "Toggle offline mode": "Attiva/disattiva modalità offline",
"Toggle panel": "Attiva/disattiva pannello", "Toggle panel": "Attiva/disattiva pannello",
"Toggle sidebar": "Attiva/disattiva barra laterale", "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.", "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", "Translations": "Traduzioni",
"UI": "UI", "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 today": "Aggiornato oggi",
"Updated yesterday": "Aggiornato ieri", "Updated yesterday": "Aggiornato ieri",
"Upload Site": "Carica sito", "Upload Site": "Carica sito",

View File

@@ -847,6 +847,113 @@ button {
display: none; 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 { .panel-header {
height: 35px; height: 35px;
display: flex; display: flex;

View 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

View File

@@ -270,6 +270,33 @@ defmodule BDS.UI.ShellTest do
assert template =~ "assistant-sidebar-transcript" assert template =~ "assistant-sidebar-transcript"
end 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 test "desktop shell css keeps the old assistant sidebar panel styling" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")