Packages
phoenix_kit
1.7.56
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/publishing/web/editor.ex
defmodule PhoenixKit.Modules.Publishing.Web.Editor do @moduledoc """ Markdown editor for publishing posts. This LiveView handles post editing with support for: - Collaborative editing (presence tracking, lock management) - AI translation - Version management - Multi-language support - Autosave - Media selection The implementation is split into submodules: - Editor.Collaborative - Presence and lock management - Editor.Translation - AI translation workflow - Editor.Versions - Version switching and creation - Editor.Forms - Form building and normalization - Editor.Persistence - Save operations - Editor.Preview - Preview mode handling - Editor.Helpers - Shared utilities """ use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitWeb.Gettext # Suppress dialyzer warnings for pattern matches @dialyzer {:nowarn_function, handle_event: 3} alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.Metadata alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes # Submodule aliases alias PhoenixKit.Modules.Publishing.Web.Editor.Collaborative alias PhoenixKit.Modules.Publishing.Web.Editor.Forms alias PhoenixKit.Modules.Publishing.Web.Editor.Helpers alias PhoenixKit.Modules.Publishing.Web.Editor.Persistence alias PhoenixKit.Modules.Publishing.Web.Editor.Preview alias PhoenixKit.Modules.Publishing.Web.Editor.Translation alias PhoenixKit.Modules.Publishing.Web.Editor.Versions alias PhoenixKit.Utils.Date, as: UtilsDate # Import publishing-specific components import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher import PhoenixKit.Modules.Publishing.Web.Components.VersionSwitcher require Logger # ============================================================================ # Template Helper Delegations # ============================================================================ defdelegate datetime_local_value(value), to: Forms defdelegate featured_image_preview_url(value), to: Helpers defdelegate format_language_list(codes), to: Helpers defdelegate build_editor_languages(post, group_slug, enabled_languages, current_language), to: Helpers # ============================================================================ # Mount # ============================================================================ @impl true def mount(params, _session, socket) do group_slug = params["group"] || params["category"] || params["type"] live_source = socket.id || "blog-editor-" <> Base.url_encode64(:crypto.strong_rand_bytes(6), padding: false) socket = socket |> assign(:project_title, Settings.get_project_title()) |> assign(:page_title, "Publishing Editor") |> assign(:group_slug, group_slug) |> assign(:group_name, Publishing.group_name(group_slug) || group_slug) |> assign(:show_media_selector, false) |> assign(:media_selection_mode, :single) |> assign(:media_selected_uuids, MapSet.new()) |> assign(:is_autosaving, false) |> assign(:autosave_timer, nil) |> assign(:slug_manually_set, false) |> assign(:last_auto_slug, "") |> assign(:url_slug_manually_set, false) |> assign(:last_auto_url_slug, "") |> assign(:live_source, live_source) |> assign(:form_key, nil) |> assign(:lock_owner?, true) |> assign(:readonly?, false) |> assign(:lock_owner_user, nil) |> assign(:spectators, []) |> assign(:other_viewers, []) |> assign(:last_activity_at, System.monotonic_time(:second)) |> assign(:lock_expiration_timer, nil) |> assign(:lock_warning_shown, false) |> assign(:form, %{}) |> assign(:post, nil) |> assign(:content, "") |> assign(:group_mode, nil) |> assign(:current_language, nil) |> assign(:current_language_enabled, true) |> assign(:current_language_known, true) |> assign(:is_primary_language, true) |> assign(:post_primary_language_status, {:ok, :current}) |> assign(:available_languages, []) |> assign(:all_enabled_languages, []) |> assign(:has_pending_changes, false) |> assign(:is_new_post, false) |> assign(:is_new_translation, false) |> assign(:public_url, nil) |> assign(:current_version, nil) |> assign(:available_versions, []) |> assign(:version_statuses, %{}) |> assign(:version_dates, %{}) |> assign(:editing_published_version, false) |> assign(:viewing_older_version, false) |> assign(:show_new_version_modal, false) |> assign(:new_version_source, nil) |> assign(:show_ai_translation, false) |> assign(:ai_enabled, Translation.ai_translation_available?()) |> assign(:ai_endpoints, Translation.list_ai_endpoints()) |> assign(:ai_selected_endpoint_id, Translation.get_default_ai_endpoint_id()) |> assign(:ai_translation_status, nil) |> assign(:ai_translation_progress, nil) |> assign(:ai_translation_total, nil) |> assign(:ai_translation_languages, []) |> assign(:show_translation_confirm, false) |> assign(:pending_translation_languages, []) |> assign(:translation_warnings, []) |> assign(:current_path, Routes.path("/admin/publishing/#{group_slug}/edit")) {:ok, socket} end @impl true def terminate(_reason, socket) do if socket.assigns[:group_slug] && socket.assigns[:post] && socket.assigns[:lock_owner?] do Collaborative.broadcast_editor_activity(socket, :left) end Collaborative.unsubscribe_from_old_post_topics(socket) Collaborative.cancel_lock_expiration_timer(socket) :ok end # ============================================================================ # Handle Params # ============================================================================ @impl true def handle_params(%{"preview_token" => token} = params, uri, socket) do endpoint = socket.endpoint || PhoenixKitWeb.Endpoint case Phoenix.Token.verify(endpoint, "blog-preview", token, max_age: 300) do {:ok, data} -> old_form_key = socket.assigns[:form_key] old_post_slug = socket.assigns[:post] && socket.assigns.post[:slug] socket = socket |> Preview.apply_preview_payload(data) |> assign(:preview_token, token) |> assign(:current_path, Preview.preview_editor_path(socket, data, token, params)) form_key = PublishingPubSub.generate_form_key( socket.assigns.group_slug, socket.assigns.post, if(socket.assigns.is_new_post, do: :new, else: :edit) ) socket = assign(socket, :form_key, form_key) socket = Collaborative.setup_collaborative_editing(socket, form_key, old_form_key: old_form_key, old_post_slug: old_post_slug ) socket = push_event(socket, "changes-status", %{ has_changes: socket.assigns.has_pending_changes }) {:noreply, socket} {:error, _reason} -> handle_params(Map.delete(params, "preview_token"), uri, socket) end end # Match both /admin/publishing/:group/new route AND legacy ?new=true def handle_params(params, _uri, %{assigns: %{live_action: :new}} = socket) when not is_map_key(params, "preview_token") do case ensure_db_mode(socket) do {:ok, socket} -> handle_new_post(socket) {:redirect, socket} -> {:noreply, socket} end end def handle_params(%{"new" => "true"} = params, _uri, socket) when not is_map_key(params, "preview_token") do case ensure_db_mode(socket) do {:ok, socket} -> handle_new_post(socket) {:redirect, socket} -> {:noreply, socket} end end # UUID-based route: /admin/publishing/:group/:post_uuid/edit def handle_params(%{"post_uuid" => post_uuid} = params, _uri, socket) when not is_map_key(params, "preview_token") do case ensure_db_mode(socket) do {:redirect, socket} -> {:noreply, socket} {:ok, socket} -> handle_uuid_post_params(socket, post_uuid, params) end end def handle_params(%{"path" => path} = params, _uri, socket) when not is_map_key(params, "preview_token") do case ensure_db_mode(socket) do {:redirect, socket} -> {:noreply, socket} {:ok, socket} -> handle_path_post_params(socket, path, params) end end def handle_params(_params, _uri, socket) do {:noreply, socket} end # Ensures the editor is in DB mode. Fresh installs (no FS posts) auto-flip to DB. # Existing FS posts require migration before editing is allowed. defp ensure_db_mode(socket) do cond do Publishing.db_storage?() -> {:ok, socket} Publishing.db_storage_direct?() -> # Cache was stale — DB says "db" mode, refresh cache and proceed {:ok, socket} not Publishing.has_any_fs_posts?() -> # Fresh install — no FS posts, auto-enable DB mode Publishing.enable_db_storage!() {:ok, socket} true -> group_slug = socket.assigns.group_slug {:redirect, socket |> put_flash( :error, gettext( "Posts must be migrated to the database before editing. Import your posts from the Publishing admin." ) ) |> push_navigate(to: Routes.path("/admin/publishing/#{group_slug}"))} end end defp handle_uuid_post_params(socket, post_uuid, params) do group_slug = socket.assigns.group_slug group_mode = Publishing.get_group_mode(group_slug) version = parse_version_param(params["v"]) language = params["lang"] case Publishing.read_post_by_uuid(post_uuid, language, version) do {:ok, post} -> all_enabled_languages = Storage.enabled_language_codes() old_form_key = socket.assigns[:form_key] old_post_slug = socket.assigns[:post] && socket.assigns.post[:slug] {socket, form_key} = if language && language not in post.available_languages do handle_new_translation_params( socket, post, group_slug, group_mode, language, post.path, all_enabled_languages ) else handle_existing_post_params( socket, post, group_slug, group_mode, post.path, all_enabled_languages ) end socket = Collaborative.setup_collaborative_editing(socket, form_key, old_form_key: old_form_key, old_post_slug: old_post_slug ) {:noreply, socket} {:error, _reason} -> {:noreply, socket |> put_flash(:error, gettext("Post not found")) |> push_navigate(to: Routes.path("/admin/publishing/#{group_slug}"))} end end defp handle_path_post_params(socket, path, params) do group_slug = socket.assigns.group_slug group_mode = Publishing.get_group_mode(group_slug) case Publishing.read_post(group_slug, path) do {:ok, post} -> all_enabled_languages = Storage.enabled_language_codes() requested_lang = Map.get(params, "lang") old_form_key = socket.assigns[:form_key] old_post_slug = socket.assigns[:post] && socket.assigns.post[:slug] {socket, form_key} = if requested_lang && requested_lang not in post.available_languages do handle_new_translation_params( socket, post, group_slug, group_mode, requested_lang, path, all_enabled_languages ) else handle_existing_post_params( socket, post, group_slug, group_mode, path, all_enabled_languages ) end socket = Collaborative.setup_collaborative_editing(socket, form_key, old_form_key: old_form_key, old_post_slug: old_post_slug ) {:noreply, socket} {:error, _reason} -> {:noreply, socket |> put_flash(:error, gettext("Post not found")) |> push_navigate(to: Routes.path("/admin/publishing/#{group_slug}"))} end end defp handle_new_post(socket) do group_slug = socket.assigns.group_slug group_mode = Publishing.get_group_mode(group_slug) all_enabled_languages = Storage.enabled_language_codes() primary_language = Storage.get_primary_language() now = UtilsDate.utc_now() |> DateTime.truncate(:second) |> Forms.floor_datetime_to_minute() virtual_post = Helpers.build_virtual_post(group_slug, group_mode, primary_language, now) form = Forms.post_form(virtual_post) form_key = PublishingPubSub.generate_form_key(group_slug, virtual_post, :new) old_form_key = socket.assigns[:form_key] old_post_slug = socket.assigns[:post] && socket.assigns.post[:slug] socket = socket |> assign(:group_mode, group_mode) |> assign(:post, virtual_post) |> assign(:group_name, Publishing.group_name(group_slug) || group_slug) |> Forms.assign_form_with_tracking(form, slug_manually_set: false) |> assign(:content, "") |> assign(:available_languages, virtual_post.available_languages) |> assign(:all_enabled_languages, all_enabled_languages) |> Helpers.assign_current_language(primary_language) |> assign(:current_path, Helpers.build_new_post_url(group_slug)) |> assign(:has_pending_changes, false) |> assign(:is_new_post, true) |> assign(:public_url, nil) |> assign(:form_key, form_key) |> assign(:current_version, 1) |> assign(:available_versions, []) |> assign(:version_statuses, %{}) |> assign(:version_dates, %{}) |> assign(:editing_published_version, false) |> assign(:saved_status, "draft") |> push_event("changes-status", %{has_changes: false}) socket = Collaborative.setup_collaborative_editing(socket, form_key, old_form_key: old_form_key, old_post_slug: old_post_slug ) {:noreply, socket} end defp parse_version_param(nil), do: nil defp parse_version_param(v) when is_integer(v), do: v defp parse_version_param(v) when is_binary(v) do case Integer.parse(v) do {n, _} -> n :error -> nil end end defp parse_version_param(_), do: nil defp handle_new_translation_params( socket, post, group_slug, group_mode, switch_to_lang, _path, all_enabled_languages ) do # DB-only: no FS path needed new_path = nil original_id = post.slug current_version = Map.get(post, :version, 1) virtual_post = post |> Map.put(:original_language, post.language) |> Map.put(:path, new_path) |> Map.put(:language, switch_to_lang) |> Map.put(:group, group_slug) |> Map.put(:content, "") |> Map.put(:metadata, Map.put(post.metadata, :title, "")) |> Map.put(:mode, post.mode) |> Map.put(:slug, post.slug) form = Forms.post_form_with_primary_status(group_slug, virtual_post, current_version) fk = PublishingPubSub.generate_form_key(group_slug, virtual_post, :edit) available_versions = Map.get(post, :available_versions, []) sock = socket |> assign(:group_mode, group_mode) |> assign(:post, virtual_post) |> assign(:group_name, Publishing.group_name(group_slug) || group_slug) |> Forms.assign_form_with_tracking(form, slug_manually_set: false) |> assign(:content, "") |> assign(:available_languages, post.available_languages) |> assign(:all_enabled_languages, all_enabled_languages) |> Helpers.assign_current_language(switch_to_lang) |> assign( :current_path, Helpers.build_edit_url(group_slug, post, lang: switch_to_lang, version: current_version ) ) |> assign(:current_version, current_version) |> assign(:available_versions, available_versions) |> assign(:version_statuses, Map.get(post, :version_statuses, %{})) |> assign(:version_dates, Map.get(post, :version_dates, %{})) |> assign( :viewing_older_version, Versions.viewing_older_version?(current_version, available_versions, switch_to_lang) ) |> assign(:has_pending_changes, false) |> assign(:is_new_translation, true) |> assign(:original_post_path, original_id) |> assign(:public_url, nil) |> assign(:form_key, fk) |> assign(:saved_status, form["status"]) |> push_event("changes-status", %{has_changes: false}) {sock, fk} end defp handle_existing_post_params( socket, post, group_slug, group_mode, _path, all_enabled_languages ) do version = Map.get(post, :version, 1) form = Forms.post_form_with_primary_status(group_slug, post, version) fk = PublishingPubSub.generate_form_key(group_slug, post, :edit) is_published = form["status"] == "published" # Seed auto-title from existing content for manual-set detection extracted_title = Metadata.extract_title_from_content(post.content || "") auto_title = if extracted_title == "Untitled", do: "", else: extracted_title form_title = Map.get(form, "title", "") title_manually_set = form_title != "" and auto_title != "" and form_title != auto_title sock = socket |> assign(:group_mode, group_mode) |> assign(:post, %{post | group: group_slug}) |> assign(:group_name, Publishing.group_name(group_slug) || group_slug) |> Forms.assign_form_with_tracking(form, last_auto_title: auto_title, title_manually_set: title_manually_set ) |> assign(:content, post.content) |> assign(:available_languages, post.available_languages) |> assign(:all_enabled_languages, all_enabled_languages) |> Helpers.assign_current_language(post.language) |> assign( :current_path, Helpers.build_edit_url(group_slug, post, version: version, lang: post.language) ) |> assign(:has_pending_changes, false) |> assign(:public_url, Helpers.build_public_url(post, post.language)) |> assign(:form_key, fk) |> assign(:current_version, Map.get(post, :version, 1)) |> assign(:available_versions, Map.get(post, :available_versions, [])) |> assign(:version_statuses, Map.get(post, :version_statuses, %{})) |> assign(:version_dates, Map.get(post, :version_dates, %{})) |> assign(:editing_published_version, is_published) |> assign( :viewing_older_version, Versions.viewing_older_version?( Map.get(post, :version, 1), Map.get(post, :available_versions, []), post.language ) ) |> assign(:is_new_translation, false) |> assign(:saved_status, Map.get(post.metadata, :status, "draft")) |> push_event("changes-status", %{has_changes: false}) {sock, fk} end # ============================================================================ # Handle Events - Form Updates # ============================================================================ @impl true def handle_event("update_meta", params, socket) do if socket.assigns[:readonly?] do {:noreply, socket} else params = params |> Map.drop(["_target"]) params = Forms.preserve_auto_url_slug(params, socket) params = Forms.preserve_auto_title(params, socket) new_form = socket.assigns.form |> Map.merge(params) |> Forms.normalize_form() slug_manually_set = if Map.has_key?(params, "slug") do slug_value = Map.get(new_form, "slug", "") slug_value != "" && slug_value != socket.assigns.last_auto_slug else socket.assigns.slug_manually_set end url_slug_manually_set = if Map.has_key?(params, "url_slug") do url_slug_value = Map.get(new_form, "url_slug", "") url_slug_value != "" && url_slug_value != socket.assigns.last_auto_url_slug else socket.assigns.url_slug_manually_set end {new_form, title_manually_set} = Forms.detect_title_manual_set(params, new_form, socket) has_changes = Forms.dirty?(socket.assigns.post, new_form, socket.assigns.content) language = Helpers.editor_language(socket.assigns) new_status = new_form["status"] # Update both metadata.status and language_statuses for the current language # This ensures the language switcher reflects the user's selection immediately current_language_statuses = Map.get(socket.assigns.post, :language_statuses, %{}) updated_language_statuses = Map.put(current_language_statuses, language, new_status) updated_post = socket.assigns.post |> Map.put(:metadata, Map.merge(socket.assigns.post.metadata, %{status: new_status})) |> Map.put(:language_statuses, updated_language_statuses) public_url = Helpers.build_public_url(updated_post, language) socket = socket |> assign(:form, new_form) |> assign(:post, updated_post) |> assign(:slug_manually_set, slug_manually_set) |> assign(:url_slug_manually_set, url_slug_manually_set) |> assign(:title_manually_set, title_manually_set) |> assign(:has_pending_changes, has_changes) |> assign(:public_url, public_url) |> clear_flash() |> push_event("changes-status", %{has_changes: has_changes}) socket = if has_changes, do: schedule_autosave(socket), else: socket Collaborative.broadcast_form_change(socket, :meta, new_form) socket = Collaborative.touch_activity(socket) {:noreply, socket} end end def handle_event("update_content", %{"content" => content}, socket) do if socket.assigns[:readonly?] do {:noreply, socket} else {socket, new_form, slug_events} = Forms.maybe_update_slug_from_content(socket, content) # Auto-update title from H1 if not manually set socket = assign(socket, :form, new_form) {socket, new_form, title_events} = Forms.maybe_update_title_from_content(socket, content) has_changes = Forms.dirty?(socket.assigns.post, new_form, content) socket = socket |> assign(:content, content) |> assign(:form, new_form) |> assign(:has_pending_changes, has_changes) |> push_event("changes-status", %{has_changes: has_changes}) socket = Forms.push_slug_events(socket, slug_events ++ title_events) socket = if has_changes, do: schedule_autosave(socket), else: socket Collaborative.broadcast_form_change(socket, :content, %{content: content, form: new_form}) socket = Collaborative.touch_activity(socket) {:noreply, socket} end end def handle_event("generate_slug_from_content", _params, socket) do if socket.assigns.group_mode == "slug" do content = socket.assigns.content || "" {socket, new_form, slug_events} = Forms.maybe_update_slug_from_content(socket, content, force: true) has_changes = Forms.dirty?(socket.assigns.post, new_form, socket.assigns.content) {:noreply, socket |> assign(:form, new_form) |> assign(:has_pending_changes, has_changes) |> push_event("changes-status", %{has_changes: has_changes}) |> Forms.push_slug_events(slug_events)} else {:noreply, socket} end end # ============================================================================ # Handle Events - Save # ============================================================================ def handle_event("save", _params, socket) when socket.assigns.has_pending_changes == false do is_new = socket.assigns[:is_new_post] || socket.assigns[:is_new_translation] if is_new do try do Persistence.perform_save(socket) rescue e -> Logger.error("Editor save failed: #{Exception.message(e)}") {:noreply, put_flash(socket, :error, gettext("Something went wrong. Please try again."))} end else {:noreply, socket} end end def handle_event("save", _params, %{assigns: %{readonly?: true}} = socket) do {:noreply, put_flash(socket, :error, gettext("Cannot save - you are spectating"))} end def handle_event("save", _params, socket) do Persistence.perform_save(socket) rescue e -> Logger.error("Editor save failed: #{Exception.message(e)}") {:noreply, put_flash(socket, :error, gettext("Something went wrong. Please try again."))} end def handle_event("noop", _params, socket), do: {:noreply, socket} # ============================================================================ # Handle Events - Media # ============================================================================ def handle_event("open_media_selector", _params, socket) do {:noreply, assign(socket, :show_media_selector, true)} end def handle_event("open_image_component_selector", _params, socket) do {:noreply, socket |> assign(:show_media_selector, true) |> assign(:inserting_image_component, true)} end def handle_event("insert_component", %{"component" => "video"}, socket) do {:noreply, push_event(socket, "phx:prompt-and-insert", %{ component: "video", prompt: "Enter YouTube URL:", placeholder: "https://youtu.be/dQw4w9WgXcQ" })} end def handle_event("insert_component", %{"component" => "cta"}, socket) do template = """ <CTA primary="true" action="/your-link">Button Text</CTA> """ {:noreply, push_event(socket, "phx:insert-at-cursor", %{text: template})} end def handle_event("insert_video_component", %{"url" => url}, socket) do template = """ <Video url="#{url}"> Optional caption text </Video> """ {:noreply, push_event(socket, "phx:insert-at-cursor", %{text: template})} end def handle_event("clear_featured_image", _params, socket) do if socket.assigns[:readonly?] do {:noreply, socket} else updated_form = Map.put(socket.assigns.form, "featured_image_uuid", "") socket = socket |> assign(:form, updated_form) |> assign(:has_pending_changes, true) |> put_flash(:info, gettext("Featured image cleared")) |> push_event("changes-status", %{has_changes: true}) |> schedule_autosave() {:noreply, socket} end end # ============================================================================ # Handle Events - AI Translation # ============================================================================ def handle_event("toggle_ai_translation", _params, socket) do {:noreply, assign(socket, :show_ai_translation, !socket.assigns.show_ai_translation)} end def handle_event("select_ai_endpoint", %{"endpoint_id" => endpoint_id}, socket) do # endpoint_id can be UUID or integer - AI module handles both endpoint_id = if endpoint_id == "", do: nil, else: endpoint_id {:noreply, assign(socket, :ai_selected_endpoint_id, endpoint_id)} end def handle_event("translate_to_all_languages", _params, socket) do target_languages = Translation.get_all_target_languages(socket) empty_opts = {:warning, gettext("No other languages enabled to translate to")} Translation.enqueue_translation(socket, target_languages, empty_opts) end def handle_event("translate_missing_languages", _params, socket) do target_languages = Translation.get_target_languages_for_translation(socket) empty_opts = {:info, gettext("All languages already have translations")} Translation.enqueue_translation(socket, target_languages, empty_opts) end def handle_event("translate_to_this_language", _params, socket) do if socket.assigns[:readonly?] do {:noreply, socket} else Translation.start_translation_to_current(socket) end end def handle_event("confirm_translation", _params, socket) do target_languages = socket.assigns.pending_translation_languages current_warnings = Translation.build_translation_warnings(socket, target_languages) if current_warnings != socket.assigns.translation_warnings do {:noreply, assign(socket, :translation_warnings, current_warnings)} else socket = socket |> assign(:show_translation_confirm, false) |> assign(:pending_translation_languages, []) |> assign(:translation_warnings, []) Translation.do_enqueue_translation(socket, target_languages) end end def handle_event("cancel_translation", _params, socket) do {:noreply, socket |> assign(:show_translation_confirm, false) |> assign(:pending_translation_languages, []) |> assign(:translation_warnings, [])} end # ============================================================================ # Handle Events - Version Management # ============================================================================ def handle_event("toggle_version_access", %{"enabled" => enabled_str}, socket) do if socket.assigns[:readonly?] do {:noreply, socket} else enabled = enabled_str == "true" post = socket.assigns.post group_slug = socket.assigns.group_slug updated_metadata = Map.put(post.metadata, :allow_version_access, enabled) updated_post = %{post | metadata: updated_metadata} scope = socket.assigns[:phoenix_kit_current_scope] params = %{"allow_version_access" => enabled} case Publishing.update_post(group_slug, updated_post, params, %{scope: scope}) do {:ok, saved_post} -> flash_msg = if enabled, do: gettext("Version access enabled - older versions are now publicly accessible"), else: gettext("Version access disabled - only live version is publicly accessible") {:noreply, socket |> assign(:post, saved_post) |> put_flash(:info, flash_msg)} {:error, _reason} -> {:noreply, put_flash(socket, :error, gettext("Failed to update version access setting"))} end end end def handle_event("switch_version", %{"version" => version_str}, socket) do version = String.to_integer(version_str) if version == socket.assigns.current_version do {:noreply, socket} else case Versions.read_version_post(socket, version) do {:ok, version_post} -> {socket, old_form_key, old_post_slug, new_form_key, actual_language, _new_path} = Versions.apply_version_switch( socket, version, version_post, &Forms.post_form_with_primary_status/3 ) socket = socket |> Helpers.assign_current_language(actual_language) |> Collaborative.cleanup_and_setup_collaborative_editing(old_form_key, new_form_key, old_post_slug: old_post_slug ) post = socket.assigns.post url = Helpers.build_edit_url(socket.assigns.group_slug, post, version: version, lang: actual_language ) {:noreply, push_patch(socket, to: url, replace: true)} {:error, _reason} -> {:noreply, put_flash(socket, :error, gettext("Version not found"))} end end end def handle_event("open_new_version_modal", _params, socket) do if socket.assigns[:readonly?] or socket.assigns[:is_new_post] do {:noreply, socket} else {:noreply, socket |> assign(:show_new_version_modal, true) |> assign(:new_version_source, nil)} end end def handle_event("close_new_version_modal", _params, socket) do {:noreply, socket |> assign(:show_new_version_modal, false) |> assign(:new_version_source, nil)} end def handle_event("set_new_version_source", %{"source" => "blank"}, socket) do {:noreply, assign(socket, :new_version_source, nil)} end def handle_event("set_new_version_source", %{"source" => version_str}, socket) do case Integer.parse(version_str) do {version, _} -> {:noreply, assign(socket, :new_version_source, version)} :error -> {:noreply, socket} end end def handle_event("create_version_from_source", _params, socket) do case Versions.create_version_from_source(socket) do {:ok, socket} -> {:noreply, socket} {:error, socket} -> {:noreply, socket} end end # ============================================================================ # Handle Events - Language Switching # ============================================================================ def handle_event("switch_language", %{"language" => new_language}, socket) do if socket.assigns[:is_new_post] do {:noreply, put_flash(socket, :error, gettext("Please save the post first before switching languages"))} else do_switch_language(socket, new_language) end end def handle_event("update_primary_language", _params, socket) do group_slug = socket.assigns.group_slug post = socket.assigns.post if post do primary_language = Storage.get_primary_language() language_name = Helpers.get_language_name(primary_language) post_identifier = post.slug || post[:uuid] case Publishing.update_post_primary_language(group_slug, post_identifier, primary_language) do :ok -> Persistence.regenerate_listing_cache(group_slug) updated_post = Map.put(post, :primary_language, primary_language) enabled_languages = socket.assigns[:all_enabled_languages] || [] editor_languages = Helpers.build_editor_languages( updated_post, group_slug, enabled_languages, socket.assigns.current_language ) socket = socket |> assign(:post, updated_post) |> assign(:editor_languages, editor_languages) |> assign(:post_primary_language_status, {:ok, :current}) |> put_flash(:info, gettext("Primary language updated: %{lang}", lang: language_name)) {:noreply, socket} {:error, reason} -> {:noreply, put_flash( socket, :error, gettext("Failed to update primary language: %{reason}", reason: inspect(reason)) )} end else {:noreply, socket} end end # ============================================================================ # Handle Events - Navigation # ============================================================================ def handle_event("preview", _params, socket) do preview_payload = Preview.build_preview_payload(socket) endpoint = socket.endpoint || PhoenixKitWeb.Endpoint token = Phoenix.Token.sign(endpoint, "blog-preview", preview_payload, max_age: 300) query_params = Preview.build_preview_query_params(preview_payload, token) query_string = case URI.encode_query(query_params) do "" -> "" encoded -> "?" <> encoded end {:noreply, push_navigate(socket, to: Routes.path("/admin/publishing/#{socket.assigns.group_slug}/preview#{query_string}") )} end def handle_event("attempt_cancel", _params, %{assigns: %{has_pending_changes: false}} = socket) do handle_event("cancel", %{}, socket) end def handle_event("attempt_cancel", _params, socket) do {:noreply, push_event(socket, "confirm-navigation", %{})} end def handle_event("cancel", _params, socket) do {:noreply, socket |> push_event("changes-status", %{has_changes: false}) |> push_navigate(to: Routes.path("/admin/publishing/#{socket.assigns.group_slug}"))} end def handle_event("back_to_list", _params, socket) do handle_event("attempt_cancel", %{}, socket) end # ============================================================================ # Handle Info - Autosave # ============================================================================ @impl true def handle_info(:autosave, socket) do if socket.assigns.has_pending_changes do socket = socket |> assign(:is_autosaving, true) |> assign(:autosave_timer, nil) |> push_event("autosave-status", %{saving: true}) {:noreply, updated_socket} = Persistence.perform_save(socket) {:noreply, updated_socket |> assign(:is_autosaving, false) |> push_event("autosave-status", %{saving: false})} else {:noreply, assign(socket, :autosave_timer, nil)} end end # ============================================================================ # Handle Info - Media # ============================================================================ def handle_info({:media_selected, file_uuids}, socket) do if socket.assigns[:readonly?] do {:noreply, assign(socket, :show_media_selector, false)} else handle_media_selected(socket, file_uuids) end end def handle_info({:media_selector_closed}, socket) do {:noreply, socket |> assign(:show_media_selector, false) |> assign(:inserting_image_component, false)} end def handle_info({:editor_content_changed, %{content: content}}, socket) do {socket, new_form, slug_events} = Forms.maybe_update_slug_from_content(socket, content) # Auto-update title from H1 if not manually set socket = assign(socket, :form, new_form) {socket, new_form, title_events} = Forms.maybe_update_title_from_content(socket, content) has_changes = Forms.dirty?(socket.assigns.post, new_form, content) socket = socket |> assign(:content, content) |> assign(:form, new_form) |> assign(:has_pending_changes, has_changes) |> push_event("changes-status", %{has_changes: has_changes}) |> Forms.push_slug_events(slug_events ++ title_events) socket = if has_changes, do: schedule_autosave(socket), else: socket {:noreply, socket} end def handle_info({:editor_insert_component, %{type: :image}}, socket) do {:noreply, socket |> assign(:show_media_selector, true) |> assign(:inserting_image_component, true)} end def handle_info({:editor_insert_component, %{type: :video}}, socket) do {:noreply, push_event(socket, "prompt-and-insert", %{type: "video"})} end def handle_info({:editor_insert_component, _}, socket), do: {:noreply, socket} def handle_info({:editor_save_requested, _}, socket), do: {:noreply, socket} # ============================================================================ # Handle Info - Collaborative Editing # ============================================================================ def handle_info({:editor_saved, form_key, source}, socket) do cond do socket.assigns.form_key == nil -> {:noreply, socket} form_key != socket.assigns.form_key -> {:noreply, socket} source == socket.id -> {:noreply, socket} true -> socket = Persistence.reload_post(socket) {:noreply, socket} end end def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do if socket.assigns[:form_key] do form_key = socket.assigns.form_key was_owner = socket.assigns[:lock_owner?] socket = Collaborative.assign_editing_role(socket, form_key) if !was_owner && socket.assigns[:lock_owner?] do socket = reload_post_on_lock_acquired(socket) Collaborative.broadcast_editor_activity(socket, :joined) {:noreply, socket} else {:noreply, socket} end else {:noreply, socket} end end def handle_info({:editor_sync_request, form_key, requester_socket_id}, socket) do if socket.assigns[:form_key] == form_key && socket.assigns[:lock_owner?] do state = %{ form: socket.assigns.form, content: socket.assigns.content } PublishingPubSub.broadcast_editor_sync_response(form_key, requester_socket_id, state) end {:noreply, socket} end def handle_info({:editor_sync_response, form_key, requester_socket_id, state}, socket) do if socket.assigns[:form_key] == form_key && requester_socket_id == socket.id && socket.assigns.readonly? do socket = Collaborative.apply_remote_form_state(socket, state) {:noreply, socket} else {:noreply, socket} end end def handle_info({:editor_form_change, form_key, payload, source}, socket) do cond do socket.assigns[:form_key] != form_key -> {:noreply, socket} source == socket.id -> {:noreply, socket} socket.assigns[:readonly?] != true -> {:noreply, socket} true -> socket = Collaborative.apply_remote_form_change(socket, payload) {:noreply, socket} end end # ============================================================================ # Handle Info - Translation Events # ============================================================================ def handle_info({:translation_started, group_slug, post_slug, target_languages}, socket) do if socket.assigns[:group_slug] == group_slug && socket.assigns[:post] && socket.assigns.post[:slug] == post_slug do {:noreply, socket |> assign(:ai_translation_status, :in_progress) |> assign(:ai_translation_progress, 0) |> assign(:ai_translation_total, length(target_languages)) |> assign(:ai_translation_languages, target_languages)} else {:noreply, socket} end end def handle_info( {:translation_progress, group_slug, post_slug, completed, total, _last_language}, socket ) do if socket.assigns[:group_slug] == group_slug && socket.assigns[:post] && socket.assigns.post[:slug] == post_slug do socket = socket |> assign(:ai_translation_progress, completed) |> assign(:ai_translation_total, total) |> Persistence.refresh_available_languages() {:noreply, socket} else {:noreply, socket} end end def handle_info({:translation_completed, group_slug, post_slug, results}, socket) do if socket.assigns[:group_slug] == group_slug && socket.assigns[:post] && socket.assigns.post[:slug] == post_slug do flash_msg = if results.failure_count > 0 do gettext("Translation completed with %{success} succeeded, %{failed} failed", success: results.success_count, failed: results.failure_count ) else gettext("Translation completed successfully for %{count} languages", count: results.success_count ) end flash_level = if results.failure_count > 0, do: :warning, else: :info current_language = socket.assigns[:current_language] succeeded_languages = results[:succeeded] || [] socket = socket |> assign(:ai_translation_status, :completed) |> assign(:ai_translation_languages, []) socket = if current_language in succeeded_languages do Persistence.reload_translated_content(socket, flash_msg, flash_level) else socket |> Persistence.refresh_available_languages() |> put_flash(flash_level, flash_msg) end {:noreply, socket} else {:noreply, socket} end end def handle_info({:translation_created, group_slug, post_slug, language}, socket) do if socket.assigns[:group_slug] == group_slug && socket.assigns[:post] && socket.assigns.post[:slug] == post_slug do case re_read_post(socket) do {:ok, updated_post} -> socket = socket |> assign(:available_languages, updated_post.available_languages) |> assign( :post, socket.assigns.post |> Map.put(:available_languages, updated_post.available_languages) |> Map.put(:language_statuses, updated_post.language_statuses) ) {:noreply, socket} {:error, _} -> available = socket.assigns[:available_languages] || [] if language in available do {:noreply, socket} else {:noreply, assign(socket, :available_languages, available ++ [language])} end end else {:noreply, socket} end end def handle_info({:translation_deleted, group_slug, post_slug, language}, socket) do if socket.assigns[:group_slug] == group_slug && socket.assigns[:post] && socket.assigns.post[:slug] == post_slug do available = socket.assigns[:available_languages] || [] updated_available = List.delete(available, language) socket = socket |> assign(:available_languages, updated_available) |> assign(:post, Map.put(socket.assigns.post, :available_languages, updated_available)) {:noreply, socket} else {:noreply, socket} end end # ============================================================================ # Handle Info - Version Events # ============================================================================ def handle_info({:post_version_created, group_slug, post_slug, version_info}, socket) do is_our_post = socket.assigns[:group_slug] == group_slug && socket.assigns[:post] && socket.assigns.post[:slug] == post_slug we_just_created = socket.assigns[:just_created_version] == true cond do !is_our_post -> {:noreply, socket} we_just_created -> # Clear the flag and don't show flash for our own action {:noreply, assign(socket, :just_created_version, nil)} true -> available_versions = version_info[:available_versions] || socket.assigns[:available_versions] socket = socket |> assign(:available_versions, available_versions) |> assign(:post, Map.put(socket.assigns.post, :available_versions, available_versions)) |> put_flash(:info, gettext("A new version was created by another editor")) {:noreply, socket} end end def handle_info({:post_version_deleted, group_slug, post_slug, deleted_version}, socket) do is_our_post = socket.assigns[:group_slug] == group_slug && socket.assigns[:post] && socket.assigns.post[:slug] == post_slug if is_our_post do {:noreply, Versions.handle_version_deleted(socket, group_slug, post_slug, deleted_version)} else {:noreply, socket} end end # Handle version published with source_id (user UUID) def handle_info( {:post_version_published, group_slug, post_slug, published_version, source_user_uuid}, socket ) do is_our_post = socket.assigns[:group_slug] == group_slug && socket.assigns[:post] && socket.assigns.post[:slug] == post_slug # Ignore if same user published (works across all their tabs) our_user_uuid = get_in(socket.assigns, [:phoenix_kit_current_scope, Access.key(:user), Access.key(:uuid)]) from_us = source_user_uuid != nil && source_user_uuid == our_user_uuid cond do !is_our_post -> {:noreply, socket} from_us -> {:noreply, socket} true -> socket = socket |> put_flash( :info, gettext("Version %{version} was published by another editor", version: published_version ) ) {:noreply, socket} end end # Handle version published without source_id (legacy format, treat as from another editor) def handle_info({:post_version_published, group_slug, post_slug, published_version}, socket) do handle_info({:post_version_published, group_slug, post_slug, published_version, nil}, socket) end # ============================================================================ # Handle Info - Lock Expiration # ============================================================================ def handle_info(:check_lock_expiration, socket) do if socket.assigns[:readonly?] do {:noreply, socket} else socket = Collaborative.check_lock_expiration(socket) {:noreply, socket} end end # ============================================================================ # Private Helpers # ============================================================================ defp reload_post_on_lock_acquired(socket) do case re_read_post(socket) do {:ok, post} -> form = Forms.post_form(post) extracted_title = Metadata.extract_title_from_content(post.content || "") auto_title = if extracted_title == "Untitled", do: "", else: extracted_title socket |> assign(:post, %{post | group: socket.assigns.group_slug}) |> Forms.assign_form_with_tracking(form, last_auto_title: auto_title, title_manually_set: false ) |> assign(:content, post.content) |> assign(:has_pending_changes, false) |> push_event("changes-status", %{has_changes: false}) |> Collaborative.maybe_start_lock_expiration_timer() {:error, _} -> socket end end defp schedule_autosave(socket) do if socket.assigns.autosave_timer do Process.cancel_timer(socket.assigns.autosave_timer) end timer_ref = Process.send_after(self(), :autosave, 2000) assign(socket, :autosave_timer, timer_ref) end defp re_read_post(socket) do post = socket.assigns.post Publishing.read_post_by_uuid(post.uuid) end defp do_switch_language(socket, new_language) do post = socket.assigns.post group_slug = socket.assigns.group_slug file_exists = new_language in post.available_languages if file_exists do switch_to_existing_language(socket, group_slug, new_language) else switch_to_new_translation(socket, post, group_slug, new_language) end end defp switch_to_existing_language(socket, group_slug, target_language) do old_form_key = socket.assigns[:form_key] if old_form_key && connected?(socket) do alias PhoenixKit.Modules.Publishing.PresenceHelpers PresenceHelpers.untrack_editing_session(old_form_key, socket) PresenceHelpers.unsubscribe_from_editing(old_form_key) PublishingPubSub.unsubscribe_from_editor_form(old_form_key) end post = socket.assigns.post url = Helpers.build_edit_url(group_slug, post, lang: target_language, version: socket.assigns[:current_version] ) {:noreply, push_patch(socket, to: url)} end defp switch_to_new_translation(socket, post, group_slug, new_language) do current_version = socket.assigns.current_version || 1 # DB-only: no FS path needed new_path = nil virtual_post = Helpers.build_virtual_translation(post, group_slug, new_language, new_path, socket) available_versions = socket.assigns.available_versions || [] new_form_key = PublishingPubSub.generate_form_key(group_slug, virtual_post, :edit) old_form_key = socket.assigns[:form_key] old_post_slug = socket.assigns[:post] && socket.assigns.post[:slug] form = Forms.post_form_with_primary_status(group_slug, virtual_post, current_version) original_id = post.slug socket = socket |> assign(:post, virtual_post) |> Forms.assign_form_with_tracking(form, slug_manually_set: false) |> assign(:content, "") |> Helpers.assign_current_language(new_language) |> assign( :viewing_older_version, Versions.viewing_older_version?(current_version, available_versions, new_language) ) |> assign(:has_pending_changes, false) |> assign(:is_new_translation, true) |> assign(:original_post_path, original_id) |> assign(:form_key, new_form_key) |> push_event("changes-status", %{has_changes: false}) socket = Collaborative.cleanup_and_setup_collaborative_editing(socket, old_form_key, new_form_key, old_post_slug: old_post_slug ) url = Helpers.build_edit_url(group_slug, post, lang: new_language, version: current_version) {:noreply, push_patch(socket, to: url, replace: true)} end defp handle_media_selected(socket, file_ids) do file_uuid = List.first(file_ids) inserting_image_component = Map.get(socket.assigns, :inserting_image_component, false) {socket, autosave?} = cond do file_uuid && inserting_image_component -> file_url = Helpers.get_file_url(file_uuid) js_code = "window.publishingEditorInsertMedia && window.publishingEditorInsertMedia('#{file_url}', 'image')" { socket |> assign(:show_media_selector, false) |> assign(:inserting_image_component, false) |> put_flash(:info, gettext("Image component inserted")) |> push_event("exec-js", %{js: js_code}), false } file_uuid -> { socket |> assign(:form, Forms.update_form_with_media(socket.assigns.form, file_uuid)) |> assign(:has_pending_changes, true) |> assign(:show_media_selector, false) |> put_flash(:info, gettext("Featured image selected")) |> push_event("changes-status", %{has_changes: true}), true } true -> {socket |> assign(:show_media_selector, false), false} end socket = if autosave?, do: schedule_autosave(socket), else: socket {:noreply, socket} endend