defmodule PhoenixKit.Modules.Blogging.Web.Editor do
@moduledoc """
Markdown editor for blogging 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 Blogging 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.Blogging
alias PhoenixKit.Modules.Blogging.Metadata
alias PhoenixKit.Modules.Blogging.PresenceHelpers
alias PhoenixKit.Modules.Blogging.PubSub, as: BloggingPubSub
alias PhoenixKit.Modules.Blogging.Renderer
alias PhoenixKit.Modules.Blogging.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, "Blogging Editor")
|> assign(:blog_slug, blog_slug)
|> assign(:blog_name, Blogging.blog_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/blogging/#{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 = Blogging.get_blog_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 = BloggingPubSub.generate_form_key(blog_slug, virtual_post, :new)
socket =
socket
|> assign(:blog_mode, blog_mode)
|> assign(:post, virtual_post)
|> assign(:blog_name, Blogging.blog_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/blogging/#{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 = Blogging.get_blog_mode(blog_slug)
case Blogging.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 = BloggingPubSub.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, Blogging.blog_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/blogging/#{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 = BloggingPubSub.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, Blogging.blog_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/blogging/#{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/blogging/#{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 = """
Button Text
"""
{:noreply, push_event(socket, "phx:insert-at-cursor", %{text: template})}
end
def handle_event("insert_video_component", %{"url" => url}, socket) do
template = """
"""
{: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 Blogging.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/blogging/#{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/blogging/#{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)
BloggingPubSub.unsubscribe_from_editor_form(old_form_key)
end
{:noreply,
push_patch(socket,
to:
Routes.path(
"/admin/blogging/#{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 = BloggingPubSub.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/blogging/#{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 Blogging.read_post(blog_slug, post.slug, language, version) do
{:ok, _} = result ->
result
{:error, _} when language != master_language ->
# Fall back to master language
Blogging.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 = BloggingPubSub.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/blogging/#{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/blogging/#{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 Blogging.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
}
BloggingPubSub.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 Blogging.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.bloggingEditorInsertMedia && window.bloggingEditorInsertMedia('#{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 Blogging.create_post(socket.assigns.blog_slug, create_opts) do
{:ok, new_post} ->
# Note: Blogging.create_post and update_post handle broadcasts internally
case Blogging.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 Blogging.add_language_to_post(
socket.assigns.blog_slug,
original_identifier,
socket.assigns.current_language,
current_version
) do
{:ok, new_post} ->
# Note: Blogging.add_language_to_post and update_post handle broadcasts internally
case Blogging.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 Blogging.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: Blogging.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/blogging/#{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 Blogging.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 Blogging.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)}"
)
BloggingPubSub.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 Blogging.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)}"
)
BloggingPubSub.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/blogging/#{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 = Blogging.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} ->
Map.put(base, "slug", String.trim(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 = Blogging.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, Blogging.blog_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/blogging/#{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/blogging/#{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)
BloggingPubSub.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)
BloggingPubSub.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(
"Blogging 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)
BloggingPubSub.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)
BloggingPubSub.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) ->
BloggingPubSub.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 Blogging.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