Files
bDS2/lib/bds/desktop/shell_live/media_editor.ex

703 lines
20 KiB
Elixir

defmodule BDS.Desktop.ShellLive.MediaEditor do
@moduledoc false
use Phoenix.LiveComponent
import Ecto.Query
alias BDS.Desktop.{FilePicker}
alias BDS.{AI, I18n, Media}
alias BDS.Media.Media, as: MediaRecord
alias BDS.Media.Translation
alias BDS.Posts.Post
alias BDS.Repo
use Gettext, backend: BDS.Gettext
embed_templates("media_editor_html/*")
@post_picker_limit 10
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
@impl true
def update(%{action: :save} = assigns, socket) do
socket =
socket
|> assign(Map.drop(assigns, [:action]))
|> do_save()
{:ok, socket}
end
def update(%{action: :close_quick_actions} = assigns, socket) do
socket =
socket
|> assign(Map.drop(assigns, [:action]))
|> assign(:quick_actions_open?, false)
|> build_data()
{:ok, socket}
end
def update(%{action: :apply_ai_suggestions, fields: fields} = assigns, socket) do
socket =
socket
|> assign(Map.drop(assigns, [:action, :fields]))
|> do_apply_ai_suggestions(fields)
{:ok, socket}
end
def update(%{action: :translate, language: language} = assigns, socket) do
socket =
socket
|> assign(Map.drop(assigns, [:action, :language]))
|> do_translate(language)
{:ok, socket}
end
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> ensure_state()
|> build_data()
{:ok, socket}
end
@spec render(map()) :: Phoenix.LiveView.Rendered.t()
@impl true
def render(%{media_editor: nil} = assigns), do: ~H"<div></div>"
def render(assigns) do
media_editor(assigns)
end
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
@impl true
def handle_event("change_media_editor", %{"media_editor" => params}, socket) do
media = socket.assigns.media
draft = normalize_params(params)
dirty? = draft != persisted_form(media)
was_dirty? = socket.assigns.dirty?
socket =
socket
|> assign(:draft, draft)
|> assign(:dirty?, dirty?)
|> build_data()
if dirty? != was_dirty? do
notify_parent({:media_editor_dirty, socket.assigns.media_id, dirty?})
end
{:noreply, socket}
end
def handle_event("save_media_editor", _params, socket) do
{:noreply, do_save(socket)}
end
def handle_event("toggle_media_editor_quick_actions", _params, socket) do
socket =
socket
|> assign(:quick_actions_open?, not socket.assigns.quick_actions_open?)
|> build_data()
{:noreply, socket}
end
def handle_event("replace_media_editor_file", _params, socket) do
media = socket.assigns.media
case FilePicker.choose_file(dgettext("ui", "Replace Media File")) do
{:ok, source_path} ->
case Media.replace_media_file(media.id, source_path) do
{:ok, %MediaRecord{} = updated_media} ->
socket =
socket
|> assign(:media, updated_media)
|> assign(:draft, persisted_form(updated_media))
|> assign(:dirty?, false)
|> build_data()
notify_parent({:media_editor_dirty, media.id, false})
notify_parent({:media_editor_tab_meta, media.id, display_title(updated_media), updated_media.original_name || updated_media.mime_type || ""})
{:noreply, socket}
{:ok, nil} ->
{:noreply, build_data(socket)}
{:error, reason} ->
notify_output(socket, dgettext("ui", "Replace File"), inspect(reason), "error")
{:noreply, build_data(socket)}
end
:cancel ->
{:noreply, socket}
{:error, %{message: message}} ->
notify_output(socket, dgettext("ui", "Replace File"), message, "error")
{:noreply, build_data(socket)}
end
end
def handle_event("detect_media_editor_language", _params, socket) do
if socket.assigns.offline_mode do
notify_output(
socket,
dgettext("ui", "Detect Language"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
"info"
)
{:noreply, build_data(socket)}
else
media = socket.assigns.media
draft = socket.assigns.draft
text =
Enum.join(
[
Map.get(draft, "title", ""),
Map.get(draft, "alt", ""),
Map.get(draft, "caption", "")
],
"\n\n"
)
case AI.detect_language(text) do
{:ok, %{language_code: language_code}}
when is_binary(language_code) and language_code != "" ->
normalized = normalize_language(language_code)
case Media.update_media(media.id, %{language: normalized}) do
{:ok, updated_media} ->
updated_draft = Map.put(draft, "language", normalized)
socket =
socket
|> assign(:media, updated_media)
|> assign(:draft, updated_draft)
|> assign(:dirty?, updated_draft != persisted_form(updated_media))
|> build_data()
{:noreply, socket}
{:error, reason} ->
notify_output(socket, dgettext("ui", "Detect Language"), inspect(reason), "error")
{:noreply, build_data(socket)}
end
{:error, reason} ->
notify_output(socket, dgettext("ui", "Detect Language"), inspect(reason), "error")
{:noreply, build_data(socket)}
_other ->
notify_output(
socket,
dgettext("ui", "Detect Language"),
dgettext("ui", "Language detection failed."),
"error"
)
{:noreply, build_data(socket)}
end
end
end
def handle_event("toggle_media_post_picker", _params, socket) do
socket =
socket
|> assign(:post_picker_open?, not socket.assigns.post_picker_open?)
|> assign(:post_picker_query, "")
|> build_data()
{:noreply, socket}
end
def handle_event("change_media_post_picker", %{"media_post_picker" => %{"query" => query}}, socket) do
socket =
socket
|> assign(:post_picker_query, to_string(query || ""))
|> build_data()
{:noreply, socket}
end
def handle_event("link_media_to_post", %{"post-id" => post_id}, socket) do
media = socket.assigns.media
case Media.link_media_to_post(media.id, post_id) do
{:ok, _linked} ->
socket =
socket
|> assign(:post_picker_open?, false)
|> assign(:post_picker_query, "")
|> build_data()
{:noreply, socket}
{:error, reason} ->
notify_output(socket, dgettext("ui", "Link to Post"), inspect(reason), "error")
{:noreply, build_data(socket)}
end
end
def handle_event("unlink_media_from_post", %{"post-id" => post_id}, socket) do
media = socket.assigns.media
case Media.unlink_media_from_post(media.id, post_id) do
{:ok, _unlinked} ->
{:noreply, build_data(socket)}
{:error, reason} ->
notify_output(socket, dgettext("ui", "Unlink from Post"), inspect(reason), "error")
{:noreply, build_data(socket)}
end
end
def handle_event("edit_media_translation", %{"language" => language}, socket) do
media = socket.assigns.media
translation = Repo.get_by(Translation, translation_for: media.id, language: language)
form = %{
"language" => language,
"title" => (translation && translation.title) || "",
"alt" => (translation && translation.alt) || "",
"caption" => (translation && translation.caption) || ""
}
socket =
socket
|> assign(:editing_translation, form)
|> build_data()
{:noreply, socket}
end
def handle_event("change_media_translation", %{"media_translation" => params}, socket) do
form = %{
"language" => Map.get(params, "language", ""),
"title" => Map.get(params, "title", ""),
"alt" => Map.get(params, "alt", ""),
"caption" => Map.get(params, "caption", "")
}
socket =
socket
|> assign(:editing_translation, form)
|> build_data()
{:noreply, socket}
end
def handle_event("save_media_translation", _params, socket) do
media = socket.assigns.media
case socket.assigns.editing_translation do
%{"language" => language} = form when language not in [nil, ""] ->
case Media.upsert_media_translation(media.id, language, %{
title: blank_to_nil(Map.get(form, "title")),
alt: blank_to_nil(Map.get(form, "alt")),
caption: blank_to_nil(Map.get(form, "caption"))
}) do
{:ok, _translation} ->
socket =
socket
|> assign(:editing_translation, nil)
|> build_data()
{:noreply, socket}
{:error, reason} ->
notify_output(socket, dgettext("ui", "Save Translation"), inspect(reason), "error")
{:noreply, build_data(socket)}
end
_other ->
{:noreply, socket}
end
end
def handle_event("close_media_translation_editor", _params, socket) do
socket =
socket
|> assign(:editing_translation, nil)
|> build_data()
{:noreply, socket}
end
def handle_event("refresh_media_translation", %{"language" => language}, socket) do
media = socket.assigns.media
if socket.assigns.offline_mode do
notify_output(
socket,
dgettext("ui", "Translate"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
"info"
)
{:noreply, build_data(socket)}
else
case AI.translate_media(media.id, normalize_language(language)) do
{:ok, translation} ->
case Media.upsert_media_translation(media.id, language, translation) do
{:ok, _saved_translation} ->
{:noreply, build_data(socket)}
{:error, reason} ->
notify_output(socket, dgettext("ui", "Refresh Translation"), inspect(reason), "error")
{:noreply, build_data(socket)}
end
{:error, reason} ->
notify_output(socket, dgettext("ui", "Refresh Translation"), inspect(reason), "error")
{:noreply, build_data(socket)}
end
end
end
def handle_event("delete_media_translation", %{"language" => language}, socket) do
media = socket.assigns.media
case Media.delete_media_translation(media.id, language) do
{:ok, _deleted?} ->
socket =
socket
|> assign(:editing_translation, nil)
|> build_data()
{:noreply, socket}
{:error, reason} ->
notify_output(socket, dgettext("ui", "Delete Translation"), inspect(reason), "error")
{:noreply, build_data(socket)}
end
end
defp ensure_state(socket) do
media_id = socket.assigns.current_tab.id
media = Media.get_media(media_id)
defaults = %{
media_id: media_id,
media: media,
draft: if(media, do: persisted_form(media), else: %{}),
quick_actions_open?: false,
post_picker_open?: false,
post_picker_query: "",
editing_translation: nil,
dirty?: false,
save_state: :idle
}
Enum.reduce(defaults, socket, fn {key, default}, acc ->
if is_nil(Map.get(acc.assigns, key)) do
assign(acc, key, default)
else
acc
end
end)
end
defp build_data(socket) do
case socket.assigns.media do
nil ->
assign(socket, :media_editor, nil)
%MediaRecord{} = media ->
linked_posts = Media.list_linked_posts(media.id)
translations = Media.list_media_translations(media.id)
draft = socket.assigns.draft
picker_query = socket.assigns.post_picker_query
{picker_results, picker_overflow_count} =
post_picker_results(media, linked_posts, picker_query)
data = %{
id: media.id,
display_title: display_title(media),
original_name: media.original_name || media.filename || media.id,
mime_type: media.mime_type || "application/octet-stream",
file_size: format_file_size(media.size),
dimensions: dimensions_label(media),
is_image: image?(media),
preview_url: preview_url(media),
dirty?: socket.assigns.dirty?,
save_state: socket.assigns.save_state,
quick_actions_open?: socket.assigns.quick_actions_open?,
post_picker_open?: socket.assigns.post_picker_open?,
post_picker_query: picker_query,
post_picker_results: picker_results,
post_picker_overflow_count: picker_overflow_count,
form: draft,
languages: language_codes(),
translations: Enum.map(translations, &translation_view/1),
editing_translation: socket.assigns.editing_translation,
linked_posts: linked_posts,
can_detect_language?: detect_language_enabled?(draft),
can_translate?: draft["language"] not in [nil, ""]
}
assign(socket, :media_editor, data)
end
end
defp do_save(socket) do
media = socket.assigns.media
case media do
nil ->
socket
%MediaRecord{} = media ->
draft = socket.assigns.draft
case persist(media, draft) do
{:ok, updated_media} ->
socket =
socket
|> assign(:media, updated_media)
|> assign(:draft, persisted_form(updated_media))
|> assign(:dirty?, false)
|> assign(:save_state, :saved)
|> build_data()
notify_parent({:media_editor_dirty, media.id, false})
notify_parent({:media_editor_tab_meta, media.id, display_title(updated_media), updated_media.original_name || updated_media.mime_type || ""})
notify_output(socket, dgettext("ui", "Media"), dgettext("ui", "Media saved"))
socket
{:error, reason} ->
notify_output(socket, dgettext("ui", "Media"), inspect(reason), "error")
|> build_data()
end
end
end
defp do_apply_ai_suggestions(socket, fields) do
media = socket.assigns.media
case media do
nil ->
socket
%MediaRecord{} = _media ->
updated_draft =
Enum.reduce(fields, socket.assigns.draft, fn field, acc ->
case field.key do
"title" -> Map.put(acc, "title", field.suggested_value)
"alt" -> Map.put(acc, "alt", field.suggested_value)
"caption" -> Map.put(acc, "caption", field.suggested_value)
_other -> acc
end
end)
dirty? = updated_draft != persisted_form(media)
socket =
socket
|> assign(:draft, updated_draft)
|> assign(:dirty?, dirty?)
|> assign(:save_state, :dirty)
|> assign(:quick_actions_open?, false)
|> build_data()
notify_parent({:media_editor_dirty, media.id, dirty?})
socket
end
end
defp do_translate(socket, language) do
if socket.assigns.offline_mode do
notify_output(
socket,
dgettext("ui", "Translate"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
"info"
)
build_data(socket)
else
media = socket.assigns.media
normalized_language = normalize_language(language)
source_language = normalize_language(media.language)
case AI.translate_media(media.id, normalized_language, source_language: source_language) do
{:ok, translation} ->
case Media.upsert_media_translation(media.id, normalized_language, translation) do
{:ok, _saved_translation} ->
socket
|> assign(:quick_actions_open?, false)
|> assign(:editing_translation, nil)
|> build_data()
{:error, reason} ->
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error")
|> build_data()
end
{:error, reason} ->
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error")
|> build_data()
end
end
end
defp notify_parent(message) do
send(self(), message)
end
defp notify_output(socket, title, message, level \\ "info") do
send(self(), {:media_editor_output, title, message, level})
socket
end
@spec persist(term(), term()) :: term()
def persist(%MediaRecord{} = media, draft) do
Media.update_media(media.id, %{
title: blank_to_nil(Map.get(draft, "title")),
alt: blank_to_nil(Map.get(draft, "alt")),
caption: blank_to_nil(Map.get(draft, "caption")),
author: blank_to_nil(Map.get(draft, "author")),
language: blank_to_nil(Map.get(draft, "language")),
tags: csv_to_list(Map.get(draft, "tags"))
})
end
defp translation_view(%Translation{} = translation) do
%{
language: translation.language,
flag: I18n.flag(translation.language),
title: translation.title,
alt: translation.alt,
caption: translation.caption
}
end
defp post_picker_results(%MediaRecord{} = media, linked_posts, query) do
linked_ids = MapSet.new(Enum.map(linked_posts, & &1.post_id))
normalized_query = normalize_query(query)
posts =
Repo.all(
from post in Post,
where: post.project_id == ^media.project_id,
order_by: [desc: post.updated_at, desc: post.created_at],
select: %{
post_id: post.id,
title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id)
}
)
|> Enum.reject(&MapSet.member?(linked_ids, &1.post_id))
|> Enum.filter(fn post ->
normalized_query == "" or String.contains?(String.downcase(post.title), normalized_query)
end)
{Enum.take(posts, @post_picker_limit), max(length(posts) - @post_picker_limit, 0)}
end
defp preview_url(%MediaRecord{} = media) do
if image?(media),
do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}",
else: nil
end
defp image?(%MediaRecord{} = media),
do: String.starts_with?(to_string(media.mime_type || ""), "image/")
defp display_title(%MediaRecord{} = media),
do: blank_to_nil(media.title) || blank_to_nil(media.original_name) || media.id
defp dimensions_label(%MediaRecord{width: width, height: height})
when is_integer(width) and is_integer(height), do: "#{width} x #{height}"
defp dimensions_label(_media), do: nil
defp format_file_size(size) when is_integer(size) and size >= 1_048_576,
do: :erlang.float_to_binary(size / 1_048_576, decimals: 1) <> " MB"
defp format_file_size(size) when is_integer(size),
do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB"
defp format_file_size(_size), do: "0.0 KB"
defp detect_language_enabled?(form) do
[Map.get(form, "title"), Map.get(form, "alt"), Map.get(form, "caption")]
|> Enum.any?(&(blank_to_nil(&1) != nil))
end
defp language_codes do
I18n.supported_languages()
|> Enum.map(& &1.code)
end
defp normalize_params(params) do
%{
"title" => Map.get(params, "title", ""),
"alt" => Map.get(params, "alt", ""),
"caption" => Map.get(params, "caption", ""),
"tags" => Map.get(params, "tags", ""),
"author" => Map.get(params, "author", ""),
"language" => Map.get(params, "language", "")
}
end
defp persisted_form(%MediaRecord{} = media) do
%{
"title" => media.title || "",
"alt" => media.alt || "",
"caption" => media.caption || "",
"tags" => Enum.join(media.tags, ", "),
"author" => media.author || "",
"language" => media.language || ""
}
end
defp normalize_query(value) do
value
|> to_string()
|> String.trim()
|> String.downcase()
end
defp csv_to_list(value) do
value
|> to_string()
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp blank_to_nil(value) do
value
|> to_string()
|> String.trim()
|> case do
"" -> nil
trimmed -> trimmed
end
end
@spec media_editor_save_state_label(term()) :: term()
def media_editor_save_state_label(:dirty), do: dgettext("ui", "Unsaved")
def media_editor_save_state_label(:saved), do: dgettext("ui", "Saved")
def media_editor_save_state_label(_state), do: dgettext("ui", "Idle")
@spec language_label(term()) :: term()
def language_label(code) do
code
|> to_string()
|> String.upcase()
end
@spec normalize_language(term()) :: term()
def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase()
end