Packages

phoenix_kit

1.7.20
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
phoenix_kit lib modules publishing web editor.ex
Raw

lib/modules/publishing/web/editor.ex

defmodule PhoenixKit.Modules.Publishing.Web.Editor do
@moduledoc """
Markdown editor for publishing posts.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
# Suppress dialyzer warnings for pattern matches in create_new_post/create_new_translation
# where dialyzer incorrectly infers that {:ok, _} patterns can never match.
# The Publishing context functions do return {:ok, _} on success.
@dialyzer {:nowarn_function, create_new_post: 2}
@dialyzer {:nowarn_function, create_new_translation: 2}
@dialyzer {:nowarn_function, handle_post_update_error: 2}
@dialyzer {:nowarn_function, handle_post_update_result: 4}
@dialyzer {:nowarn_function, handle_event: 3}
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.Metadata
alias PhoenixKit.Modules.Publishing.PresenceHelpers
alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
alias PhoenixKit.Modules.Publishing.Renderer
alias PhoenixKit.Modules.Publishing.Storage
alias PhoenixKit.Modules.Storage.URLSigner
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
alias PhoenixKitWeb.BlogHTML
require Logger
@impl true
def mount(params, _session, socket) do
# Attach locale hook for automatic locale handling
blog_slug = params["blog"] || params["category"] || params["type"]
# Generate a unique source ID for this socket to prevent self-echoing
live_source =
socket.id ||
"blog-editor-" <> Base.url_encode64(:crypto.strong_rand_bytes(6), padding: false)
socket =
socket
|> assign(:project_title, Settings.get_setting("project_title", "PhoenixKit"))
|> assign(:page_title, "Publishing Editor")
|> assign(:blog_slug, blog_slug)
|> assign(:blog_name, Publishing.group_name(blog_slug) || blog_slug)
|> assign(:show_media_selector, false)
|> assign(:media_selection_mode, :single)
|> assign(:media_selected_ids, MapSet.new())
|> assign(:is_autosaving, false)
|> assign(:autosave_timer, nil)
|> assign(:slug_manually_set, false)
|> assign(:last_auto_slug, "")
# Collaborative editing assigns
|> 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, [])
# Default editor form/content assigns (will be overridden by handle_params)
|> assign(:form, %{})
|> assign(:post, nil)
|> assign(:content, "")
|> assign(:blog_mode, nil)
|> assign(:current_language, nil)
|> assign(:current_language_enabled, true)
|> assign(:current_language_known, true)
|> assign(:is_master_language, true)
|> 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)
# Version-related assigns
|> assign(:current_version, nil)
|> assign(:available_versions, [])
|> assign(:version_statuses, %{})
|> assign(:version_dates, %{})
|> assign(:editing_published_version, false)
|> assign(:viewing_older_version, false)
|> assign(
:current_path,
Routes.path("/admin/publishing/#{blog_slug}/edit",
locale: socket.assigns.current_locale_base
)
)
{:ok, socket}
end
@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} ->
socket =
socket
|> apply_preview_payload(data)
|> assign(:preview_token, token)
|> assign(:current_path, preview_editor_path(socket, data, token, params))
|> push_event("changes-status", %{has_changes: true})
{:noreply, socket}
{:error, _reason} ->
handle_params(Map.delete(params, "preview_token"), uri, socket)
end
end
def handle_params(%{"new" => "true"}, _uri, socket) do
blog_slug = socket.assigns.blog_slug
blog_mode = Publishing.get_group_mode(blog_slug)
all_enabled_languages = Storage.enabled_language_codes()
primary_language = hd(all_enabled_languages)
now = DateTime.utc_now() |> DateTime.truncate(:second) |> floor_datetime_to_minute()
virtual_post = build_virtual_post(blog_slug, blog_mode, primary_language, now)
form = post_form(virtual_post)
# Generate form key for new post (for collaborative editing)
form_key = PublishingPubSub.generate_form_key(blog_slug, virtual_post, :new)
socket =
socket
|> assign(:blog_mode, blog_mode)
|> assign(:post, virtual_post)
|> assign(:blog_name, Publishing.group_name(blog_slug) || blog_slug)
|> 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)
|> assign_current_language(primary_language)
|> assign(
:current_path,
Routes.path("/admin/publishing/#{blog_slug}/edit?new=true",
locale: socket.assigns.current_locale_base
)
)
|> assign(:has_pending_changes, false)
|> assign(:is_new_post, true)
|> assign(:public_url, nil)
|> assign(:form_key, form_key)
# New posts start at version 1
|> assign(:current_version, 1)
|> assign(:available_versions, [])
|> assign(:version_statuses, %{})
|> assign(:version_dates, %{})
|> assign(:editing_published_version, false)
|> push_event("changes-status", %{has_changes: false})
# Set up collaborative editing for new posts
socket = setup_collaborative_editing(socket, form_key)
{:noreply, socket}
end
def handle_params(%{"path" => path} = params, _uri, socket)
when not is_map_key(params, "preview_token") do
blog_slug = socket.assigns.blog_slug
blog_mode = Publishing.get_group_mode(blog_slug)
case Publishing.read_post(blog_slug, path) do
{:ok, post} ->
all_enabled_languages = Storage.enabled_language_codes()
switch_to_lang = Map.get(params, "switch_to")
{socket, form_key} =
if switch_to_lang && switch_to_lang not in post.available_languages do
new_path =
path
|> Path.dirname()
|> Path.join("#{switch_to_lang}.phk")
virtual_post =
post
|> Map.put(:path, new_path)
|> Map.put(:language, switch_to_lang)
|> Map.put(:blog, blog_slug)
|> Map.put(:content, "")
|> Map.put(:metadata, Map.put(post.metadata, :title, ""))
|> Map.put(:mode, post.mode)
|> Map.put(:slug, post.slug)
form = post_form(virtual_post)
fk = PublishingPubSub.generate_form_key(blog_slug, virtual_post, :edit)
# Recalculate viewing_older_version for the new translation language
# Translations (non-master languages) should not be locked
current_version = Map.get(post, :version, 1)
available_versions = Map.get(post, :available_versions, [])
sock =
socket
|> assign(:blog_mode, blog_mode)
|> assign(:post, virtual_post)
|> assign(:blog_name, Publishing.group_name(blog_slug) || blog_slug)
|> assign_form_with_tracking(form, slug_manually_set: false)
|> assign(:content, "")
|> assign(:available_languages, post.available_languages)
|> assign(:all_enabled_languages, all_enabled_languages)
|> assign_current_language(switch_to_lang)
|> assign(
:current_path,
Routes.path(
"/admin/publishing/#{blog_slug}/edit?path=#{URI.encode_www_form(new_path)}",
locale: socket.assigns.current_locale_base
)
)
|> 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,
viewing_older_version?(current_version, available_versions, switch_to_lang)
)
|> assign(:has_pending_changes, false)
|> assign(:is_new_translation, true)
|> assign(:original_post_path, path)
|> assign(:public_url, nil)
|> assign(:form_key, fk)
|> push_event("changes-status", %{has_changes: false})
{sock, fk}
else
form = post_form(post)
fk = PublishingPubSub.generate_form_key(blog_slug, post, :edit)
# Check if we're editing a published version
is_published = Map.get(post.metadata, :status) == "published"
sock =
socket
|> assign(:blog_mode, blog_mode)
|> assign(:post, %{post | blog: blog_slug})
|> assign(:blog_name, Publishing.group_name(blog_slug) || blog_slug)
|> assign_form_with_tracking(form)
|> assign(:content, post.content)
|> assign(:available_languages, post.available_languages)
|> assign(:all_enabled_languages, all_enabled_languages)
|> assign_current_language(post.language)
|> assign(
:current_path,
Routes.path(
"/admin/publishing/#{blog_slug}/edit?path=#{URI.encode_www_form(path)}",
locale: socket.assigns.current_locale_base
)
)
|> assign(:has_pending_changes, false)
|> assign(:public_url, build_public_url(post, post.language))
|> assign(:form_key, fk)
# Version-related assigns
|> 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,
viewing_older_version?(
Map.get(post, :version, 1),
Map.get(post, :available_versions, []),
post.language
)
)
|> push_event("changes-status", %{has_changes: false})
{sock, fk}
end
# Set up collaborative editing
socket = setup_collaborative_editing(socket, form_key)
{:noreply, socket}
{:error, _reason} ->
{:noreply,
socket
|> put_flash(:error, gettext("Post not found"))
|> push_navigate(
to:
Routes.path("/admin/publishing/#{blog_slug}",
locale: socket.assigns.current_locale_base
)
)}
end
end
# Catch-all for other param combinations (shouldn't normally be reached)
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end
@impl true
def handle_event("update_meta", params, socket) do
# Spectators cannot edit
if socket.assigns[:readonly?] do
{:noreply, socket}
else
params =
params
|> Map.drop(["_target"])
# No real-time validation - accept any input, validation happens on save
new_form =
socket.assigns.form
|> Map.merge(params)
|> 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
has_changes = dirty?(socket.assigns.post, new_form, socket.assigns.content)
# Update public_url if status changed
updated_post = %{
socket.assigns.post
| metadata: Map.merge(socket.assigns.post.metadata, %{status: new_form["status"]})
}
language = editor_language(socket.assigns)
public_url = build_public_url(updated_post, language)
socket =
socket
|> assign(:form, new_form)
|> assign(:slug_manually_set, slug_manually_set)
|> assign(:has_pending_changes, has_changes)
|> assign(:public_url, public_url)
|> clear_flash()
|> push_event("changes-status", %{has_changes: has_changes})
# Trigger debounced autosave if changes detected
socket =
if has_changes do
schedule_autosave(socket)
else
socket
end
{:noreply, socket}
end
end
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
# Spectators cannot edit
if socket.assigns[:readonly?] do
{:noreply, socket}
else
# Clear the featured image from the form (form is a simple map, not a struct)
updated_form = Map.put(socket.assigns.form, "featured_image_id", "")
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
def handle_event("generate_slug_from_content", _params, socket) do
# Only generate slug if in slug mode and slug is empty
if socket.assigns.blog_mode == "slug" do
content = socket.assigns.content || ""
{socket, new_form, slug_events} =
maybe_update_slug_from_content(socket, content, force: true)
has_changes = 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})
|> push_slug_events(slug_events)}
else
{:noreply, socket}
end
end
def handle_event("update_content", %{"content" => content}, socket) do
# Spectators cannot edit
if socket.assigns[:readonly?] do
{:noreply, socket}
else
{socket, new_form, slug_events} = maybe_update_slug_from_content(socket, content)
has_changes = 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 =
push_slug_events(socket, slug_events)
# Trigger debounced autosave if changes detected
socket =
if has_changes do
schedule_autosave(socket)
else
socket
end
{:noreply, socket}
end
end
def handle_event("save", _params, %{assigns: %{has_pending_changes: false}} = socket) do
{:noreply, socket}
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
perform_save(socket)
end
def handle_event("noop", _params, socket), do: {:noreply, socket}
def handle_event("toggle_version_access", %{"enabled" => enabled_str}, socket) do
# Spectators cannot edit
if socket.assigns[:readonly?] do
{:noreply, socket}
else
enabled = enabled_str == "true"
post = socket.assigns.post
blog_slug = socket.assigns.blog_slug
# Update the metadata with the new setting
updated_metadata = Map.put(post.metadata, :allow_version_access, enabled)
updated_post = %{post | metadata: updated_metadata}
# Save the change to disk
scope = socket.assigns[:phoenix_kit_current_scope]
params = %{"allow_version_access" => enabled}
case Publishing.update_post(blog_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("preview", _params, socket) do
preview_payload = build_preview_payload(socket)
endpoint = socket.endpoint || PhoenixKitWeb.Endpoint
token = Phoenix.Token.sign(endpoint, "blog-preview", preview_payload, max_age: 300)
query_params =
%{"preview_token" => token}
|> maybe_put_preview_path(preview_payload.path)
|> maybe_put_preview_new_flag(preview_payload)
query_string =
case URI.encode_query(query_params) do
"" -> ""
encoded -> "?" <> encoded
end
{:noreply,
push_navigate(socket,
to:
Routes.path(
"/admin/publishing/#{socket.assigns.blog_slug}/preview#{query_string}",
locale: socket.assigns.current_locale_base
)
)}
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.blog_slug}",
locale: socket.assigns.current_locale_base
)
)}
end
def handle_event("back_to_list", _params, socket) do
handle_event("attempt_cancel", %{}, socket)
end
def handle_event("switch_language", %{"language" => new_language}, socket) do
post = socket.assigns.post
blog_slug = socket.assigns.blog_slug
base_dir = slug_base_dir(post, blog_slug)
new_path = Path.join(base_dir, "#{new_language}.phk")
file_exists = new_language in post.available_languages
if file_exists do
# Clean up old presence before switching to existing language file
old_form_key = socket.assigns[:form_key]
if old_form_key && connected?(socket) do
PresenceHelpers.untrack_editing_session(old_form_key, socket)
PresenceHelpers.unsubscribe_from_editing(old_form_key)
PublishingPubSub.unsubscribe_from_editor_form(old_form_key)
end
{:noreply,
push_patch(socket,
to:
Routes.path(
"/admin/publishing/#{blog_slug}/edit?path=#{URI.encode(new_path)}",
locale: socket.assigns.current_locale_base
)
)}
else
virtual_post =
post
|> Map.put(:path, new_path)
|> Map.put(:language, new_language)
|> Map.put(:blog, blog_slug || "blog")
|> Map.put(:content, "")
|> Map.put(:metadata, Map.put(post.metadata, :title, ""))
|> Map.put(:mode, post.mode)
|> Map.put(:slug, post.slug || Map.get(socket.assigns.form, "slug"))
# Recalculate viewing_older_version for the new language
# Translations (non-master languages) should not be locked
current_version = socket.assigns.current_version || 1
available_versions = socket.assigns.available_versions || []
# Generate new form_key for the new language
new_form_key = PublishingPubSub.generate_form_key(blog_slug, virtual_post, :edit)
# Save old form_key BEFORE assigning new one (for presence cleanup)
old_form_key = socket.assigns[:form_key]
socket =
socket
|> assign(:post, virtual_post)
|> assign_form_with_tracking(post_form(virtual_post), slug_manually_set: false)
|> assign(:content, "")
|> assign_current_language(new_language)
|> assign(
:viewing_older_version,
viewing_older_version?(current_version, available_versions, new_language)
)
|> assign(:has_pending_changes, false)
|> assign(:is_new_translation, true)
|> assign(:original_post_path, post.path || post.slug)
|> assign(:form_key, new_form_key)
|> push_event("changes-status", %{has_changes: false})
# Clean up old presence and set up new (pass old_form_key explicitly)
socket = cleanup_and_setup_collaborative_editing(socket, old_form_key, new_form_key)
# Update the URL to reflect the new language using switch_to parameter
# (since the new translation file doesn't exist yet)
original_path = socket.assigns[:original_post_path] || post.path || post.slug
{:noreply,
push_patch(socket,
to:
Routes.path(
"/admin/publishing/#{blog_slug}/edit?path=#{URI.encode(original_path)}&switch_to=#{new_language}",
locale: socket.assigns.current_locale_base
),
replace: true
)}
end
end
def handle_event("switch_version", %{"version" => version_str}, socket) do
version = String.to_integer(version_str)
current_version = socket.assigns.current_version
if version == current_version do
{:noreply, socket}
else
blog_slug = socket.assigns.blog_slug
post = socket.assigns.post
language = socket.assigns.current_language
blog_mode = socket.assigns.blog_mode
master_language = Storage.get_master_language()
# Read the specified version based on blog mode
# Try current language first, then fall back to master language
read_result =
case blog_mode do
"slug" ->
case Publishing.read_post(blog_slug, post.slug, language, version) do
{:ok, _} = result ->
result
{:error, _} when language != master_language ->
# Fall back to master language
Publishing.read_post(blog_slug, post.slug, master_language, version)
error ->
error
end
_ ->
# For timestamp mode, build the versioned path
case read_timestamp_version(blog_slug, post, language, version) do
{:ok, _} = result ->
result
{:error, _} when language != master_language ->
# Fall back to master language
read_timestamp_version(blog_slug, post, master_language, version)
error ->
error
end
end
case read_result do
{:ok, version_post} ->
form = post_form(version_post)
is_published = Map.get(version_post.metadata, :status) == "published"
actual_language = version_post.language
# Generate new form_key for the new version
new_form_key = PublishingPubSub.generate_form_key(blog_slug, version_post, :edit)
# Save old form_key BEFORE assigning new one (for presence cleanup)
old_form_key = socket.assigns[:form_key]
socket =
socket
|> assign(:post, %{version_post | blog: blog_slug})
|> assign_form_with_tracking(form)
|> assign(:content, version_post.content)
|> assign(:current_version, version)
|> assign(:available_versions, version_post.available_versions)
|> assign(:version_statuses, version_post.version_statuses)
|> assign(:version_dates, Map.get(version_post, :version_dates, %{}))
|> assign(:available_languages, version_post.available_languages)
|> assign(:editing_published_version, is_published)
|> assign(
:viewing_older_version,
viewing_older_version?(version, version_post.available_versions, actual_language)
)
|> assign(:has_pending_changes, false)
|> assign(:public_url, build_public_url(version_post, actual_language))
|> assign_current_language(actual_language)
|> assign(:form_key, new_form_key)
|> push_event("changes-status", %{has_changes: false})
|> push_event("set-content", %{content: version_post.content})
# Clean up old presence and set up new (pass old_form_key explicitly)
socket = cleanup_and_setup_collaborative_editing(socket, old_form_key, new_form_key)
# Update URL to reflect the new version's path
{:noreply,
push_patch(socket,
to:
Routes.path(
"/admin/publishing/#{blog_slug}/edit?path=#{URI.encode(version_post.path)}",
locale: socket.assigns.current_locale_base
),
replace: true
)}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Version not found"))}
end
end
end
def handle_event("migrate_to_versioned", _params, socket) do
post = socket.assigns.post
if post && post.is_legacy_structure do
language = socket.assigns.current_language
case Storage.migrate_post_to_versioned(post, language) do
{:ok, migrated_post} ->
# Refresh the form with the migrated post
form = post_form(migrated_post)
socket =
socket
|> assign(:post, %{migrated_post | blog: socket.assigns.blog_slug})
|> assign_form_with_tracking(form)
|> assign(:content, migrated_post.content)
|> assign(:current_version, migrated_post.version)
|> assign(:available_versions, migrated_post.available_versions)
|> assign(:version_statuses, migrated_post.version_statuses)
|> assign(:version_dates, Map.get(migrated_post, :version_dates, %{}))
|> assign(
:viewing_older_version,
viewing_older_version?(
migrated_post.version,
migrated_post.available_versions,
language
)
)
|> assign(:has_pending_changes, false)
|> push_event("changes-status", %{has_changes: false})
|> put_flash(:info, gettext("Post migrated to versioned structure (v1)"))
|> push_patch(
to:
Routes.path(
"/admin/publishing/#{socket.assigns.blog_slug}/edit?path=#{URI.encode(migrated_post.path)}",
locale: socket.assigns.current_locale_base
)
)
{:noreply, socket}
{:error, reason} ->
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to migrate post: %{reason}", reason: inspect(reason))
)}
end
else
{:noreply, socket}
end
end
# Helper to read a specific version for timestamp-mode posts
defp read_timestamp_version(blog_slug, post, language, version) do
# Build the versioned path for timestamp mode
date_str = Date.to_iso8601(post.date)
time_str = format_time_folder(post.time)
versioned_path = Path.join([blog_slug, date_str, time_str, "v#{version}", "#{language}.phk"])
Storage.read_post(blog_slug, versioned_path)
end
defp format_time_folder(%Time{} = time) do
{hour, minute, _second} = Time.to_erl(time)
"#{String.pad_leading(to_string(hour), 2, "0")}:#{String.pad_leading(to_string(minute), 2, "0")}"
end
@impl true
def handle_info(:autosave, socket) do
# Only autosave if there are pending changes
if socket.assigns.has_pending_changes do
socket =
socket
|> assign(:is_autosaving, true)
|> assign(:autosave_timer, nil)
|> push_event("autosave-status", %{saving: true})
# Perform the save
{:noreply, updated_socket} = 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
def handle_info({:media_selected, file_ids}, socket) do
# Spectators cannot edit
if socket.assigns[:readonly?] do
{:noreply, assign(socket, :show_media_selector, false)}
else
handle_media_selected(socket, file_ids)
end
end
def handle_info({:media_selector_closed}, socket) do
{:noreply,
socket
|> assign(:show_media_selector, false)
|> assign(:inserting_image_component, false)}
end
# Handle content changes from MarkdownEditor component
def handle_info({:editor_content_changed, %{content: content}}, socket) do
{socket, new_form, slug_events} = maybe_update_slug_from_content(socket, content)
has_changes = 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})
|> push_slug_events(slug_events)
socket =
if has_changes do
schedule_autosave(socket)
else
socket
end
{:noreply, socket}
end
# Handle image insert from MarkdownEditor toolbar
def handle_info({:editor_insert_component, %{type: :image}}, socket) do
{:noreply,
socket
|> assign(:show_media_selector, true)
|> assign(:inserting_image_component, true)}
end
# Handle video insert from MarkdownEditor toolbar
def handle_info({:editor_insert_component, %{type: :video}}, socket) do
{:noreply, push_event(socket, "prompt-and-insert", %{type: "video"})}
end
# Catch-all for other MarkdownEditor events
def handle_info({:editor_insert_component, _}, socket), do: {:noreply, socket}
def handle_info({:editor_save_requested, _}, socket), do: {:noreply, socket}
# Handle save broadcasts from other users/tabs (last-save-wins sync)
def handle_info({:editor_saved, form_key, source}, socket) do
Logger.debug(
"EDITOR_SAVED received: form_key=#{inspect(form_key)}, source=#{inspect(source)}, " <>
"my_form_key=#{inspect(socket.assigns[:form_key])}, my_socket_id=#{inspect(socket.id)}"
)
cond do
# Ignore if no form_key set
socket.assigns.form_key == nil ->
Logger.debug("EDITOR_SAVED ignored: no form_key")
{:noreply, socket}
# Ignore if different form
form_key != socket.assigns.form_key ->
Logger.debug("EDITOR_SAVED ignored: different form_key")
{:noreply, socket}
# Ignore our own save broadcast
source == socket.id ->
Logger.debug("EDITOR_SAVED ignored: own broadcast")
{:noreply, socket}
# Another tab/user saved - reload from disk to get their changes
true ->
Logger.debug("EDITOR_SAVED: reloading from disk!")
socket = reload_post_from_disk(socket)
{:noreply, socket}
end
end
# Handle presence changes (users joining/leaving)
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?]
# Re-evaluate our role
socket = assign_editing_role(socket, form_key)
# If we were promoted from spectator to owner, we can now edit
if !was_owner && socket.assigns[:lock_owner?] do
# Reload the post from disk to get fresh state
socket =
case Publishing.read_post(socket.assigns.blog_slug, socket.assigns.post.path) do
{:ok, post} ->
form = post_form(post)
socket
|> assign(:post, %{post | blog: socket.assigns.blog_slug})
|> assign_form_with_tracking(form)
|> assign(:content, post.content)
|> assign(:has_pending_changes, false)
|> push_event("changes-status", %{has_changes: false})
{:error, _} ->
socket
end
{:noreply, socket}
else
{:noreply, socket}
end
else
{:noreply, socket}
end
end
# Handle sync requests from new spectators
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
# Send current state to the requester
state = %{
form: socket.assigns.form,
content: socket.assigns.content
}
PublishingPubSub.broadcast_editor_sync_response(form_key, requester_socket_id, state)
end
{:noreply, socket}
end
# Handle sync responses (when we're a new spectator)
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 = apply_remote_form_state(socket, state)
{:noreply, socket}
else
{:noreply, socket}
end
end
# Handle translation created events (update language selector in real-time)
def handle_info({:translation_created, blog_slug, post_slug, language}, socket) do
# Only update if this is the same post
if socket.assigns[:blog_slug] == blog_slug &&
socket.assigns[:post] &&
socket.assigns.post[:slug] == post_slug do
# Refresh available languages by re-reading the post's languages from disk
case Publishing.read_post(blog_slug, socket.assigns.post.path) do
{:ok, updated_post} ->
socket =
socket
|> assign(:available_languages, updated_post.available_languages)
|> assign(
:post,
Map.put(socket.assigns.post, :available_languages, updated_post.available_languages)
)
{:noreply, socket}
{:error, _} ->
# If we can't read (e.g., file deleted), just add the language to available list
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
# Handle translation deleted events
def handle_info({:translation_deleted, blog_slug, post_slug, language}, socket) do
if socket.assigns[:blog_slug] == blog_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
# Helper for handle_info({:media_selected, ...})
defp handle_media_selected(socket, file_ids) do
# Handle the selected file IDs from the media selector modal
file_id = List.first(file_ids)
inserting_image_component = Map.get(socket.assigns, :inserting_image_component, false)
{socket, autosave?} =
cond do
file_id && inserting_image_component ->
# Insert image with standard markdown syntax at cursor via JavaScript
file_url = get_file_url(file_id)
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_id ->
{
socket
|> assign(:form, update_form_with_media(socket.assigns.form, file_id))
|> 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}
end
# Get a URL for a file from storage (for standard markdown image syntax)
defp get_file_url(file_id) do
URLSigner.signed_url(file_id, "original")
end
defp schedule_autosave(socket) do
# Cancel existing timer if any
if socket.assigns.autosave_timer do
Process.cancel_timer(socket.assigns.autosave_timer)
end
# Schedule new autosave
timer_ref = Process.send_after(self(), :autosave, 2000)
assign(socket, :autosave_timer, timer_ref)
end
defp perform_save(socket) do
params =
socket.assigns.form
|> Map.take(["status", "published_at", "slug", "featured_image_id"])
|> Map.put("content", socket.assigns.content)
params =
case {socket.assigns.blog_mode, Map.get(params, "slug")} do
{"slug", slug} when is_binary(slug) and slug != "" ->
params
{"slug", _} ->
Map.delete(params, "slug")
_ ->
Map.delete(params, "slug")
end
is_new_post = Map.get(socket.assigns, :is_new_post, false)
is_new_translation = Map.get(socket.assigns, :is_new_translation, false)
cond do
is_new_post ->
create_new_post(socket, params)
is_new_translation ->
create_new_translation(socket, params)
true ->
update_existing_post(socket, params)
end
end
defp create_new_post(socket, params) do
scope = socket.assigns[:phoenix_kit_current_scope]
create_opts =
if socket.assigns.blog_mode == "slug" do
%{
title: Map.get(params, "title"),
slug: Map.get(params, "slug")
}
else
%{}
end
|> Map.put(:scope, scope)
case Publishing.create_post(socket.assigns.blog_slug, create_opts) do
{:ok, new_post} ->
# Note: Publishing.create_post and update_post handle broadcasts internally
case Publishing.update_post(socket.assigns.blog_slug, new_post, params, %{scope: scope}) do
{:ok, _updated_post} = result ->
handle_post_update_result(socket, result, gettext("Post created and saved"), %{
is_new_post: false
})
error ->
handle_post_update_result(socket, error, gettext("Post created and saved"), %{
is_new_post: false
})
end
{:error, error} ->
handle_post_creation_error(socket, error, gettext("Failed to create post"))
end
end
defp create_new_translation(socket, params) do
scope = socket.assigns[:phoenix_kit_current_scope]
original_identifier =
case socket.assigns.blog_mode do
"slug" ->
socket.assigns.post.slug ||
Map.get(socket.assigns, :original_post_path, socket.assigns.post.path)
_ ->
Map.get(socket.assigns, :original_post_path, socket.assigns.post.path)
end
# Pass the current version to ensure translation is created in the right version
current_version = socket.assigns[:current_version]
case Publishing.add_language_to_post(
socket.assigns.blog_slug,
original_identifier,
socket.assigns.current_language,
current_version
) do
{:ok, new_post} ->
# Note: Publishing.add_language_to_post and update_post handle broadcasts internally
case Publishing.update_post(socket.assigns.blog_slug, new_post, params, %{scope: scope}) do
{:ok, _updated_post} = result ->
handle_post_update_result(socket, result, gettext("Translation created and saved"), %{
is_new_translation: false,
original_post_path: nil
})
error ->
handle_post_update_result(socket, error, gettext("Translation created and saved"), %{
is_new_translation: false,
original_post_path: nil
})
end
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Failed to create translation file"))}
end
end
defp update_existing_post(socket, params) do
scope = socket.assigns[:phoenix_kit_current_scope]
post = socket.assigns.post
language = socket.assigns.current_language
old_path = post.path
# Check if we need to create a new version (editing published content)
# This applies to both slug-mode and timestamp-mode blogs
# Only create versions for posts that have been migrated to versioned structure
should_create_version =
not Map.get(post, :is_legacy_structure, false) and
Storage.should_create_new_version?(post, params, language)
if should_create_version do
# Create new version instead of updating in place
create_new_version_from_edit(socket, params, scope)
else
update_post_in_place(socket, params, scope, old_path)
end
end
defp create_new_version_from_edit(socket, params, scope) do
blog_slug = socket.assigns.blog_slug
post = socket.assigns.post
case Publishing.create_new_version(blog_slug, post, params, %{scope: scope}) do
{:ok, new_version_post} ->
# Invalidate cache for the post
invalidate_post_cache(blog_slug, new_version_post)
# Note: Publishing.create_new_version handles broadcasts internally
form = post_form(new_version_post)
socket =
socket
|> assign(:post, new_version_post)
|> assign_form_with_tracking(form)
|> assign(:content, new_version_post.content)
|> assign(:current_version, new_version_post.version)
|> assign(:available_versions, new_version_post.available_versions)
|> assign(:version_statuses, new_version_post.version_statuses)
|> assign(:version_dates, Map.get(new_version_post, :version_dates, %{}))
|> assign(:editing_published_version, false)
|> assign(:has_pending_changes, false)
|> push_event("changes-status", %{has_changes: false})
|> put_flash(
:info,
gettext("Created new version %{version} (draft)", version: new_version_post.version)
)
|> push_patch(
to:
Routes.path(
"/admin/publishing/#{blog_slug}/edit?path=#{URI.encode(new_version_post.path)}",
locale: socket.assigns.current_locale_base
)
)
{:noreply, socket}
{:error, reason} ->
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to create new version: %{reason}", reason: inspect(reason))
)}
end
end
defp update_post_in_place(socket, params, scope, old_path) do
case Publishing.update_post(socket.assigns.blog_slug, socket.assigns.post, params, %{
scope: scope
}) do
{:ok, post} ->
# Invalidate cache for this post
invalidate_post_cache(socket.assigns.blog_slug, post)
# Note: Post list broadcast is now handled in Publishing.update_post/4
# Broadcast save to other tabs/users so they can reload (for editor sync)
if socket.assigns[:form_key] do
Logger.debug(
"BROADCASTING editor_saved from update_existing_post: " <>
"form_key=#{inspect(socket.assigns.form_key)}, source=#{inspect(socket.id)}"
)
PublishingPubSub.broadcast_editor_saved(socket.assigns.form_key, socket.id)
end
flash_message =
if socket.assigns.is_autosaving,
do: nil,
else: gettext("Post saved")
# Re-read post from disk to get fresh cross-version statuses
# (language_statuses and version_statuses are calculated on read)
# IMPORTANT: Pass the current version to avoid jumping to latest version
current_version = socket.assigns[:current_version]
current_language = socket.assigns[:current_language]
refreshed_post =
case Publishing.read_post(
socket.assigns.blog_slug,
post.path,
current_language,
current_version
) do
{:ok, fresh_post} -> fresh_post
{:error, _} -> post
end
form = post_form(refreshed_post)
# Check if post is now published (for versioning message updates)
is_published = Map.get(refreshed_post.metadata, :status) == "published"
socket =
socket
|> assign(:post, refreshed_post)
|> assign_form_with_tracking(form)
|> assign(:content, refreshed_post.content)
|> assign(:has_pending_changes, false)
|> assign(:editing_published_version, is_published)
|> assign(:language_statuses, refreshed_post.language_statuses)
|> assign(:version_statuses, refreshed_post.version_statuses)
|> assign(:version_dates, Map.get(refreshed_post, :version_dates, %{}))
|> push_event("changes-status", %{has_changes: false})
|> maybe_update_current_path(old_path, refreshed_post.path)
{:noreply, if(flash_message, do: put_flash(socket, :info, flash_message), else: socket)}
{:error, :invalid_format} ->
{:noreply,
put_flash(
socket,
:error,
gettext(
"Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-post-title)"
)
)}
{:error, :reserved_language_code} ->
{:noreply,
put_flash(
socket,
:error,
gettext(
"This slug is reserved because it's a language code (like 'en', 'es', 'fr'). Please choose a different slug to avoid routing conflicts."
)
)}
{:error, :invalid_slug} ->
{:noreply,
put_flash(
socket,
:error,
gettext(
"Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-post-title)"
)
)}
{:error, :slug_already_exists} ->
{:noreply, put_flash(socket, :error, gettext("A post with that slug already exists"))}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Failed to save post"))}
end
end
# Helper function to handle post update results and reduce cyclomatic complexity
defp handle_post_update_result(socket, update_result, success_message, extra_assigns) do
case update_result do
{:ok, updated_post} ->
# Invalidate cache for the post
invalidate_post_cache(socket.assigns.blog_slug, updated_post)
# Broadcast save to other tabs/users so they can reload
if socket.assigns[:form_key] do
Logger.debug(
"BROADCASTING editor_saved: " <>
"form_key=#{inspect(socket.assigns.form_key)}, source=#{inspect(socket.id)}"
)
PublishingPubSub.broadcast_editor_saved(socket.assigns.form_key, socket.id)
end
flash_message =
if socket.assigns.is_autosaving,
do: nil,
else: success_message
form = post_form(updated_post)
socket =
socket
|> assign(:post, updated_post)
|> assign_form_with_tracking(form)
|> assign(:content, updated_post.content)
|> assign(:available_languages, updated_post.available_languages)
|> assign(:has_pending_changes, false)
|> assign(extra_assigns)
|> push_event("changes-status", %{has_changes: false})
|> push_patch(
to:
Routes.path(
"/admin/publishing/#{socket.assigns.blog_slug}/edit?path=#{URI.encode(updated_post.path)}",
locale: socket.assigns.current_locale_base
)
)
{:noreply, if(flash_message, do: put_flash(socket, :info, flash_message), else: socket)}
{:error, error} ->
handle_post_update_error(socket, error)
end
end
# Helper function to handle post update errors
defp handle_post_update_error(socket, :invalid_format) do
{:noreply,
put_flash(
socket,
:error,
gettext(
"Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-post-title)"
)
)}
end
defp handle_post_update_error(socket, :reserved_language_code) do
{:noreply,
put_flash(
socket,
:error,
gettext(
"This slug is reserved because it's a language code (like 'en', 'es', 'fr'). Please choose a different slug to avoid routing conflicts."
)
)}
end
defp handle_post_update_error(socket, :invalid_slug) do
{:noreply,
put_flash(
socket,
:error,
gettext(
"Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-post-title)"
)
)}
end
defp handle_post_update_error(socket, :slug_already_exists) do
{:noreply, put_flash(socket, :error, gettext("A post with that slug already exists"))}
end
defp handle_post_update_error(socket, _reason) do
{:noreply, put_flash(socket, :error, gettext("Failed to save post"))}
end
# Helper function to assign current language with enabled/known status
# This tracks whether the current language is enabled in the Languages module
# and whether it's a recognized language code (vs unknown files like "test.phk")
defp assign_current_language(socket, language_code) do
enabled_languages = socket.assigns[:all_enabled_languages] || []
lang_info = Publishing.get_language_info(language_code)
master_language = Storage.get_master_language()
socket
|> assign(:current_language, language_code)
|> assign(
:current_language_enabled,
Storage.language_enabled?(language_code, enabled_languages)
)
|> assign(:current_language_known, lang_info != nil)
|> assign(:is_master_language, language_code == master_language)
end
# Helper function to handle post creation errors
defp handle_post_creation_error(socket, :invalid_slug, _fallback_message) do
{:noreply,
put_flash(
socket,
:error,
gettext(
"Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-post-title)"
)
)}
end
defp handle_post_creation_error(socket, :slug_already_exists, _fallback_message) do
{:noreply, put_flash(socket, :error, gettext("A post with that slug already exists"))}
end
defp handle_post_creation_error(socket, _reason, fallback_message) do
{:noreply, put_flash(socket, :error, fallback_message)}
end
defp post_form(post) do
base = %{
"status" => post.metadata.status || "draft",
"published_at" =>
post.metadata.published_at ||
DateTime.utc_now()
|> floor_datetime_to_minute()
|> DateTime.to_iso8601(),
"featured_image_id" => Map.get(post.metadata, :featured_image_id, "")
}
form =
cond do
Map.get(post, :mode) == :slug ->
Map.put(base, "slug", post.slug || Map.get(post.metadata, :slug) || "")
Map.get(post, "mode") == :slug ->
Map.put(
base,
"slug",
post["slug"] || Map.get(post, :slug) || Map.get(post.metadata, :slug) || ""
)
true ->
base
end
normalize_form(form)
end
defp floor_datetime_to_minute(%DateTime{} = datetime) do
%DateTime{datetime | second: 0, microsecond: {0, 0}}
end
defp dirty?(post, form, content) do
normalized_form = normalize_form(form)
normalized_form != post_form(post) || content != post.content
end
defp normalize_form(form) when is_map(form) do
featured_image_id =
form
|> Map.get("featured_image_id", "")
|> to_string()
|> String.trim()
base =
%{
"status" => Map.get(form, "status", "draft") || "draft",
"published_at" => normalize_published_at(Map.get(form, "published_at")),
"featured_image_id" => featured_image_id
}
case Map.fetch(form, "slug") do
{:ok, slug} ->
# Normalize slug: trim and downcase to match validation requirements
normalized_slug = slug |> to_string() |> String.trim() |> String.downcase()
Map.put(base, "slug", normalized_slug)
:error ->
base
end
end
defp normalize_form(_),
do: %{
"status" => "draft",
"published_at" => "",
"slug" => "",
"featured_image_id" => ""
}
defp datetime_local_value(nil), do: ""
defp datetime_local_value(value) do
case DateTime.from_iso8601(value) do
{:ok, dt, _} ->
dt
|> floor_datetime_to_minute()
|> DateTime.to_naive()
|> NaiveDateTime.to_iso8601()
_ ->
value
end
end
defp normalize_published_at(nil), do: ""
defp normalize_published_at(value) when is_binary(value) do
trimmed = String.trim(value)
cond do
trimmed == "" ->
""
String.length(trimmed) == 16 and String.contains?(trimmed, "T") ->
trimmed <> ":00Z"
true ->
case DateTime.from_iso8601(trimmed) do
{:ok, dt, _} ->
dt
|> floor_datetime_to_minute()
|> DateTime.to_iso8601()
_ ->
trimmed
end
end
end
defp normalize_published_at(_), do: ""
defp featured_image_preview_url(value) do
case sanitize_featured_image_id(value) do
nil ->
nil
file_id ->
BlogHTML.featured_image_url(%{metadata: %{featured_image_id: file_id}}, "medium")
end
end
defp sanitize_featured_image_id(value) when is_binary(value) do
value
|> String.trim()
|> case do
"" -> nil
trimmed -> trimmed
end
end
defp sanitize_featured_image_id(_), do: nil
@doc """
Builds language data for the blog_language_switcher component in the editor.
Returns a list of language maps with status, exists flag, enabled flag, and code.
The `enabled` field indicates if the language is currently active in the Languages module.
The `known` field indicates if the language code is recognized (vs unknown files like "test.phk").
Uses preloaded `language_statuses` from the post when available to avoid re-reading files.
"""
def build_editor_languages(post, _blog_slug, enabled_languages, current_language) do
# Use shared ordering function for consistent display across all views
all_languages =
Storage.order_languages_for_display(post.available_languages || [], enabled_languages)
# Get preloaded language statuses (falls back to empty map for backwards compatibility)
language_statuses = Map.get(post, :language_statuses) || %{}
Enum.map(all_languages, fn lang_code ->
lang_info = Publishing.get_language_info(lang_code)
file_exists = lang_code in (post.available_languages || [])
is_current = lang_code == current_language
is_enabled = Storage.language_enabled?(lang_code, enabled_languages)
is_known = lang_info != nil
# Use preloaded status instead of re-reading file
status = Map.get(language_statuses, lang_code)
# Get display code (base or full dialect depending on enabled languages)
display_code = Storage.get_display_code(lang_code, enabled_languages)
%{
code: lang_code,
display_code: display_code,
name: if(lang_info, do: lang_info.name, else: lang_code),
flag: if(lang_info, do: lang_info.flag, else: ""),
status: status,
exists: file_exists,
is_current: is_current,
enabled: is_enabled,
known: is_known
}
end)
end
defp build_preview_payload(socket) do
form = socket.assigns.form || %{}
post = socket.assigns.post
metadata = %{
status: map_get_with_fallback(form, "status", metadata_value(post, :status), "draft"),
published_at:
map_get_with_fallback(
form,
"published_at",
metadata_value(post, :published_at),
""
),
slug: preview_slug(form, post)
}
%{
blog_slug: socket.assigns.blog_slug,
path: post.path,
mode: Map.get(post, :mode) || Map.get(post, "mode") || infer_mode(socket),
language: socket.assigns.current_language,
available_languages: post.available_languages || [],
metadata: metadata,
content: socket.assigns.content || "",
is_new_post:
Map.get(socket.assigns, :is_new_post, false) ||
is_nil(post.path)
}
end
defp maybe_put_preview_path(params, path) when is_binary(path) and path != "" do
Map.put(params, "path", path)
end
defp maybe_put_preview_path(params, _), do: params
defp maybe_put_preview_new_flag(params, %{is_new_post: true}) do
Map.put(params, "new", "true")
end
defp maybe_put_preview_new_flag(params, _), do: params
defp map_get_with_fallback(map, key, fallback, default) do
case Map.get(map, key) do
nil -> fallback || default
value -> value
end
end
defp preview_slug(form, post) do
form_slug =
form
|> Map.get("slug")
|> case do
nil -> nil
slug -> String.trim(to_string(slug))
end
cond do
form_slug && form_slug != "" ->
form_slug
Map.get(post, :slug) && post.slug != "" ->
post.slug
Map.get(post, "slug") && post["slug"] != "" ->
post["slug"]
metadata_value(post, :slug) ->
metadata_value(post, :slug)
metadata_value(post, "slug") ->
metadata_value(post, "slug")
true ->
""
end
end
defp metadata_value(post, key) do
metadata = Map.get(post, :metadata) || %{}
cond do
is_atom(key) ->
Map.get(metadata, key) || Map.get(metadata, Atom.to_string(key))
is_binary(key) ->
Map.get(metadata, key) ||
try do
Map.get(metadata, String.to_existing_atom(key))
rescue
ArgumentError -> nil
end
true ->
nil
end
end
defp apply_preview_payload(socket, data) do
blog_slug = data[:blog_slug] || socket.assigns.blog_slug
mode = data[:mode] || :timestamp
language = data[:language] || socket.assigns.current_language || "en"
metadata = normalize_preview_metadata(data[:metadata] || %{}, mode)
post = build_preview_post(data, blog_slug, mode, language, metadata)
form = build_preview_form(metadata, mode)
apply_preview_assigns(socket, post, form, blog_slug, mode, data)
end
defp build_preview_post(data, blog_slug, mode, language, metadata) do
{date, time} = derive_datetime_fields(mode, metadata[:published_at])
path = data[:path] || derive_preview_path(blog_slug, metadata[:slug], language, mode)
full_path = if path, do: Storage.absolute_path(path), else: nil
available_languages = data[:available_languages] || []
available_languages =
[language | available_languages] |> Enum.reject(&is_nil/1) |> Enum.uniq()
%{
blog: blog_slug,
slug: metadata[:slug],
date: date,
time: time,
path: path,
full_path: full_path,
metadata: metadata,
content: data[:content] || "",
language: language,
available_languages: available_languages,
mode: mode
}
end
defp build_preview_form(metadata, mode) do
%{
"title" => metadata[:title] || "",
"status" => metadata[:status] || "draft",
"published_at" => metadata[:published_at] || ""
}
|> maybe_put_form_slug(metadata[:slug], mode)
|> normalize_form()
end
defp apply_preview_assigns(socket, post, form, blog_slug, mode, data) do
language = post.language
socket
|> assign(:blog_mode, mode_to_string(mode))
|> assign(:blog_slug, blog_slug)
|> assign(:post, post)
|> assign_form_with_tracking(form, slug_manually_set: false)
|> assign(:content, data[:content] || "")
|> assign(:available_languages, post.available_languages)
|> assign(:all_enabled_languages, Storage.enabled_language_codes())
|> assign_current_language(language)
|> assign(:has_pending_changes, true)
|> assign(:is_new_post, data[:is_new_post] || false)
|> assign(:public_url, build_public_url(post, language))
|> assign(:blog_name, Publishing.group_name(blog_slug) || blog_slug)
end
defp normalize_preview_metadata(metadata, mode) do
metadata_map =
Enum.reduce(metadata, %{}, fn
{key, value}, acc when key in [:status, :published_at, :slug] ->
Map.put(acc, key, value)
{"status", value}, acc ->
Map.put(acc, :status, value)
{"published_at", value}, acc ->
Map.put(acc, :published_at, value)
{"slug", value}, acc ->
Map.put(acc, :slug, value)
_, acc ->
acc
end)
defaults =
case mode do
:slug -> %{status: "draft", published_at: "", slug: ""}
_ -> %{status: "draft", published_at: "", slug: nil}
end
Map.merge(defaults, metadata_map)
end
defp derive_datetime_fields(:timestamp, published_at) do
with value when is_binary(value) and value != "" <- published_at,
{:ok, dt, _offset} <- DateTime.from_iso8601(value) do
floored = floor_datetime_to_minute(dt)
{DateTime.to_date(floored), DateTime.to_time(floored)}
else
_ -> {nil, nil}
end
end
defp derive_datetime_fields(_, _), do: {nil, nil}
defp derive_preview_path(_blog_slug, _slug, _language, :timestamp), do: nil
defp derive_preview_path(blog_slug, slug, language, :slug)
when is_binary(slug) and slug != "" do
Path.join([blog_slug, slug, "#{language}.phk"])
end
defp derive_preview_path(_, _, _, _), do: nil
defp maybe_put_form_slug(form, slug, :slug) do
Map.put(form, "slug", slug || "")
end
defp maybe_put_form_slug(form, _slug, _mode), do: form
defp mode_to_string(:slug), do: "slug"
defp mode_to_string(_), do: "timestamp"
defp preview_editor_path(socket, data, token, params) do
blog_slug = data[:blog_slug] || socket.assigns.blog_slug
query_params =
%{}
|> maybe_put_preview_path(Map.get(params, "path") || data[:path])
|> maybe_put_preview_new_flag(%{is_new_post: data[:is_new_post] || false})
|> Map.put("preview_token", token)
query =
case URI.encode_query(query_params) do
"" -> ""
encoded -> "?" <> encoded
end
Routes.path("/admin/publishing/#{blog_slug}/edit#{query}",
locale: socket.assigns.current_locale_base
)
end
defp infer_mode(socket) do
case socket.assigns[:blog_mode] do
"slug" -> :slug
:slug -> :slug
_ -> :timestamp
end
end
defp build_virtual_post(blog_slug, "slug", primary_language, now) do
%{
blog: blog_slug,
date: nil,
time: nil,
path: nil,
full_path: nil,
metadata: %{
title: "",
status: "draft",
published_at: DateTime.to_iso8601(now),
slug: "",
featured_image_id: nil
},
content: "",
language: primary_language,
available_languages: [],
mode: :slug,
slug: nil,
is_legacy_structure: false
}
end
defp build_virtual_post(blog_slug, _mode, primary_language, now) do
date = DateTime.to_date(now)
time = DateTime.to_time(now)
time_folder =
"#{String.pad_leading(to_string(time.hour), 2, "0")}:#{String.pad_leading(to_string(time.minute), 2, "0")}"
%{
blog: blog_slug,
date: date,
time: time,
path:
Path.join([
blog_slug,
Date.to_iso8601(date),
time_folder,
"#{primary_language}.phk"
]),
full_path: nil,
metadata: %{
title: "",
status: "draft",
published_at: DateTime.to_iso8601(now),
featured_image_id: nil
},
content: "",
language: primary_language,
available_languages: [],
mode: :timestamp,
is_legacy_structure: false
}
end
defp slug_base_dir(post, blog_slug) do
cond do
# For versioned posts, use the path to preserve version directory
# e.g., "blog/post-slug/v1/en.phk" -> "blog/post-slug/v1"
post.path && not Map.get(post, :is_legacy_structure, false) ->
Path.dirname(post.path)
# For legacy slug mode posts without versioning
Map.get(post, :mode) == :slug and Map.get(post, :slug) ->
Path.join([blog_slug || "blog", post.slug])
# Fallback to path dirname if available
post.path ->
Path.dirname(post.path)
true ->
Path.join([blog_slug || "blog", post.slug || ""])
end
end
defp build_public_url(post, language) do
# Only show public URL for published posts
if Map.get(post.metadata, :status) == "published" do
build_url_for_mode(post, language)
else
nil
end
end
defp build_url_for_mode(post, language) do
blog_slug = post.blog || "blog"
case Map.get(post, :mode) do
:slug -> build_slug_mode_url(blog_slug, post, language)
:timestamp -> build_timestamp_mode_url(blog_slug, post, language)
_ -> nil
end
end
defp build_slug_mode_url(blog_slug, post, language) do
if post.slug do
BlogHTML.build_post_url(blog_slug, post, language)
else
nil
end
end
defp build_timestamp_mode_url(blog_slug, post, language) do
if post.metadata.published_at do
case DateTime.from_iso8601(post.metadata.published_at) do
{:ok, _datetime, _} -> BlogHTML.build_post_url(blog_slug, post, language)
_ -> nil
end
else
nil
end
end
defp editor_language(assigns) do
assigns[:current_language] ||
assigns |> Map.get(:post, %{}) |> Map.get(:language) ||
hd(Storage.enabled_language_codes())
end
defp invalidate_post_cache(blog_slug, post) do
# Determine identifier based on post mode
identifier =
case Map.get(post, :mode) do
:slug -> post.slug
:timestamp -> extract_identifier_from_path(post.path)
_ -> post.slug || extract_identifier_from_path(post.path)
end
# Call the Renderer module's cache invalidation
# Note: The Renderer uses content-hash keys, so this mainly logs the invalidation request
# The actual cache will be automatically invalidated when content hash changes
Renderer.invalidate_cache(blog_slug, identifier, post.language)
end
defp extract_identifier_from_path(path) when is_binary(path) do
# For timestamp mode: "blog/2025-01-15/09:30/en.phk" -> "2025-01-15/09:30"
# For slug mode: "blog/getting-started/en.phk" -> "getting-started"
path
|> String.split("/")
# Remove language.phk
|> Enum.drop(-1)
# Remove blog name
|> Enum.drop(1)
|> Enum.join("/")
end
defp update_form_with_media(form, file_id) do
# Update the form with the selected file_id
# The form is a simple map with string keys
Map.put(form, "featured_image_id", file_id)
end
defp assign_form_with_tracking(socket, form, opts \\ []) do
slug = Map.get(form, "slug", "")
slug_manually_set =
case Keyword.fetch(opts, :slug_manually_set) do
{:ok, value} -> value
:error -> Map.get(socket.assigns, :slug_manually_set, false)
end
last_auto_slug =
case Keyword.fetch(opts, :last_auto_slug) do
{:ok, value} -> value
:error -> slug
end
socket
|> assign(:form, form)
|> assign(:last_auto_slug, last_auto_slug)
|> assign(:slug_manually_set, slug_manually_set)
end
defp maybe_update_slug_from_content(socket, content, opts \\ []) do
force? = Keyword.get(opts, :force, false)
content = content || ""
cond do
socket.assigns.blog_mode != "slug" ->
no_slug_update(socket)
not force? && Map.get(socket.assigns, :slug_manually_set, false) ->
no_slug_update(socket)
String.trim(content) == "" ->
no_slug_update(socket)
true ->
update_slug_from_content(socket, content)
end
end
defp no_slug_update(socket), do: {socket, socket.assigns.form, []}
defp update_slug_from_content(socket, content) do
title = Metadata.extract_title_from_content(content)
current_slug = socket.assigns.post.slug || Map.get(socket.assigns.form, "slug", "")
case Storage.generate_unique_slug(socket.assigns.blog_slug, title, nil,
current_slug: current_slug
) do
{:ok, ""} ->
no_slug_update(socket)
{:ok, new_slug} ->
apply_new_slug(socket, new_slug)
{:error, _reason} ->
no_slug_update(socket)
end
end
defp apply_new_slug(socket, new_slug) do
current_slug = Map.get(socket.assigns.form, "slug", "")
if new_slug != current_slug do
form =
socket.assigns.form
|> Map.put("slug", new_slug)
|> normalize_form()
socket =
socket
|> assign(:last_auto_slug, new_slug)
|> assign(:slug_manually_set, false)
{socket, form, [{"update-slug", %{slug: new_slug}}]}
else
socket =
socket
|> assign(:last_auto_slug, new_slug)
|> assign(:slug_manually_set, false)
{socket, socket.assigns.form, []}
end
end
defp push_slug_events(socket, events) do
Enum.reduce(events, socket, fn {event, data}, acc ->
push_event(acc, event, data)
end)
end
defp maybe_update_current_path(socket, old_path, new_path)
when new_path in [nil, ""] or new_path == old_path,
do: socket
defp maybe_update_current_path(socket, _old_path, new_path) do
encoded = URI.encode(new_path)
path =
Routes.path(
"/admin/publishing/#{socket.assigns.blog_slug}/edit?path=#{encoded}",
locale: socket.assigns.current_locale_base
)
socket
|> assign(:current_path, path)
|> push_patch(to: path)
end
# ============================================================================
# Collaborative Editing
# ============================================================================
# Used when we know the old form_key (e.g., when switching languages/versions)
defp cleanup_and_setup_collaborative_editing(socket, old_form_key, new_form_key) do
current_user = socket.assigns[:phoenix_kit_current_user]
if connected?(socket) && new_form_key && current_user do
try do
# Clean up old presence tracking
if old_form_key && old_form_key != new_form_key do
PresenceHelpers.untrack_editing_session(old_form_key, socket)
PresenceHelpers.unsubscribe_from_editing(old_form_key)
PublishingPubSub.unsubscribe_from_editor_form(old_form_key)
end
# Track this user in Presence
case PresenceHelpers.track_editing_session(new_form_key, socket, current_user) do
{:ok, _ref} -> :ok
{:error, {:already_tracked, _pid, _topic, _key}} -> :ok
end
# Subscribe to presence changes and form events
PresenceHelpers.subscribe_to_editing(new_form_key)
PublishingPubSub.subscribe_to_editor_form(new_form_key)
# Determine our role (owner or spectator)
socket = assign_editing_role(socket, new_form_key)
# Load spectator state if we're not the owner
if socket.assigns.readonly? do
load_spectator_state(socket, new_form_key)
else
socket
end
rescue
ArgumentError ->
socket
|> assign(:lock_owner?, true)
|> assign(:readonly?, false)
|> assign(:lock_owner_user, nil)
|> assign(:spectators, [])
|> assign(:other_viewers, [])
end
else
socket
|> assign(:lock_owner?, true)
|> assign(:readonly?, false)
|> assign(:lock_owner_user, nil)
|> assign(:spectators, [])
|> assign(:other_viewers, [])
end
end
# Used for initial setup (reads old_form_key from socket.assigns)
defp setup_collaborative_editing(socket, form_key) do
current_user = socket.assigns[:phoenix_kit_current_user]
if connected?(socket) && form_key && current_user do
do_setup_collaborative_editing(socket, form_key, current_user)
else
assign_default_editing_state(socket)
end
end
defp do_setup_collaborative_editing(socket, form_key, current_user) do
old_form_key = socket.assigns[:form_key]
try do
cleanup_old_presence(old_form_key, form_key, socket)
track_and_subscribe(form_key, socket, current_user)
subscribe_to_post_translations(socket)
socket
|> assign_editing_role(form_key)
|> maybe_load_spectator_state(form_key)
rescue
ArgumentError ->
Logger.warning(
"Publishing Presence not available - collaborative editing disabled. " <>
"Ensure PhoenixKit.Supervisor starts before your Endpoint in application.ex"
)
assign_default_editing_state(socket)
end
end
defp cleanup_old_presence(old_form_key, form_key, socket) do
if old_form_key && old_form_key != form_key do
PresenceHelpers.untrack_editing_session(old_form_key, socket)
PresenceHelpers.unsubscribe_from_editing(old_form_key)
PublishingPubSub.unsubscribe_from_editor_form(old_form_key)
end
end
defp track_and_subscribe(form_key, socket, current_user) do
case PresenceHelpers.track_editing_session(form_key, socket, current_user) do
{:ok, _ref} -> :ok
{:error, {:already_tracked, _pid, _topic, _key}} -> :ok
end
PresenceHelpers.subscribe_to_editing(form_key)
PublishingPubSub.subscribe_to_editor_form(form_key)
end
defp subscribe_to_post_translations(socket) do
case socket.assigns[:post] && socket.assigns.post[:slug] do
post_slug when is_binary(post_slug) ->
PublishingPubSub.subscribe_to_post_translations(socket.assigns.blog_slug, post_slug)
_ ->
:ok
end
end
defp maybe_load_spectator_state(socket, form_key) do
if socket.assigns.readonly?, do: load_spectator_state(socket, form_key), else: socket
end
defp assign_default_editing_state(socket) do
socket
|> assign(:lock_owner?, true)
|> assign(:readonly?, false)
|> assign(:lock_owner_user, nil)
|> assign(:spectators, [])
|> assign(:other_viewers, [])
end
defp assign_editing_role(socket, form_key) do
current_user = socket.assigns[:phoenix_kit_current_user]
case PresenceHelpers.get_editing_role(form_key, socket.id, current_user.id) do
{:owner, _presences} ->
# I'm the owner - I can edit
socket
|> assign(:lock_owner?, true)
|> assign(:readonly?, false)
|> populate_presence_info(form_key)
{:spectator, _owner_meta, _presences} ->
# Different user is the owner - I'm read-only
socket
|> assign(:lock_owner?, false)
|> assign(:readonly?, true)
|> populate_presence_info(form_key)
end
end
defp populate_presence_info(socket, form_key) do
presences = PresenceHelpers.get_sorted_presences(form_key)
my_user_id =
socket.assigns[:phoenix_kit_current_user] && socket.assigns.phoenix_kit_current_user.id
{lock_owner_user, spectators, other_viewers} =
case presences do
[] ->
{nil, [], []}
[{_owner_socket_id, owner_meta} | spectator_list] ->
spectators =
Enum.map(spectator_list, fn {_socket_id, meta} ->
%{
user: meta.user,
user_id: meta.user_id,
user_email: meta.user_email
}
end)
# Other viewers = all presences from OTHER users (not just other sockets)
# This prevents showing your own stale sockets during page refresh
other_viewers =
presences
|> Enum.reject(fn {_socket_id, meta} -> meta.user_id == my_user_id end)
|> Enum.map(fn {_socket_id, meta} ->
%{
user: meta.user,
user_id: meta.user_id,
user_email: meta.user_email
}
end)
|> Enum.uniq_by(& &1.user_id)
{owner_meta.user, spectators, other_viewers}
end
socket
|> assign(:lock_owner_user, lock_owner_user)
|> assign(:spectators, spectators)
|> assign(:other_viewers, other_viewers)
end
defp load_spectator_state(socket, form_key) do
# Owner might have unsaved changes - sync from their Presence metadata
case PresenceHelpers.get_lock_owner(form_key) do
%{form_state: form_state} when not is_nil(form_state) ->
apply_remote_form_state(socket, form_state)
_ ->
# No form state to sync
socket
end
end
defp apply_remote_form_state(socket, form_state) do
form = Map.get(form_state, :form) || Map.get(form_state, "form") || socket.assigns.form
content =
Map.get(form_state, :content) || Map.get(form_state, "content") || socket.assigns.content
socket
|> assign(:form, form)
|> assign(:content, content)
|> assign(:has_pending_changes, true)
end
# Reload post from disk when another tab/user saves (last-save-wins)
defp reload_post_from_disk(socket) do
blog_slug = socket.assigns.blog_slug
post_path = socket.assigns.post.path
case Publishing.read_post(blog_slug, post_path) do
{:ok, updated_post} ->
form = post_form(updated_post)
socket
|> assign(:post, %{updated_post | blog: blog_slug})
|> assign_form_with_tracking(form)
|> assign(:content, updated_post.content)
|> assign(:available_languages, updated_post.available_languages)
|> assign(:has_pending_changes, false)
|> push_event("changes-status", %{has_changes: false})
|> push_event("set-content", %{content: updated_post.content})
|> put_flash(:info, gettext("Post updated by another user"))
{:error, _reason} ->
# File might have been deleted or moved
socket
|> put_flash(
:warning,
gettext("Could not reload post - it may have been moved or deleted")
)
end
end
# Check if viewing an older version AND editing master language
# Returns true only if:
# 1. current_version < max(available_versions) (newer version exists)
# 2. current_language is the master language (explicit content language setting)
# Translations on older versions can still be edited freely
defp viewing_older_version?(current_version, available_versions, current_language)
when is_integer(current_version) and is_list(available_versions) do
case available_versions do
[] ->
false
versions ->
is_older_version = current_version < Enum.max(versions)
# Use explicit content language setting for master language detection
master_language = Storage.get_master_language()
is_master_language = current_language == master_language
# Only lock if older version AND master language
is_older_version and is_master_language
end
end
defp viewing_older_version?(_current_version, _available_versions, _current_language), do: false
end