Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_posts/web/edit.ex
defmodule PhoenixKitPosts.Web.Edit do
@moduledoc """
LiveView for creating and editing posts.
Provides a comprehensive post editor with:
- Basic post fields (title, subtitle, content, type, status)
- Tag and mention management
- Group assignment
- Scheduled publishing
- SEO slug generation
## Route
- New post: `{prefix}/admin/posts/new`
- Edit post: `{prefix}/admin/posts/:id/edit`
Note: `{prefix}` is your configured PhoenixKit URL prefix (default: `/phoenix_kit`).
"""
use PhoenixKitWeb, :live_view
alias Phoenix.Component
import Leaf, only: [leaf_editor: 1]
alias PhoenixKit.Modules.Storage.URLSigner
alias PhoenixKit.Settings
alias PhoenixKit.Users.Roles
alias PhoenixKit.Utils.Routes
@impl true
def mount(params, _session, socket) do
# Get current user
current_user = socket.assigns[:phoenix_kit_current_user]
# Get project title
project_title = Settings.get_project_title()
# Determine if this is a new post or editing existing
post_uuid = Map.get(params, "id")
socket =
if post_uuid do
# Editing existing post
case PhoenixKitPosts.get_post(post_uuid,
preload: [:user, :media, :tags, :groups, :mentions]
) do
nil ->
socket
|> put_flash(:error, "Post not found")
|> push_navigate(to: Routes.path("/admin/posts"))
post ->
# Check if user owns this post or is admin
if can_edit_post?(current_user, post) do
content = post.content || ""
form_data = %{
"title" => post.title || "",
"sub_title" => post.sub_title || "",
"type" => post.type || "post",
"status" => post.status || "draft",
"slug" => post.slug || "",
"scheduled_at" => format_datetime_local(post.scheduled_at, current_user)
}
form = Component.to_form(form_data, as: :post)
socket
|> assign(:page_title, "Edit Post")
|> assign(:project_title, project_title)
|> assign(:post, post)
|> assign(:form, form)
|> assign(:content, content)
|> assign(:current_user, current_user)
|> load_form_data()
else
socket
|> put_flash(:error, "You don't have permission to edit this post")
|> push_navigate(to: Routes.path("/admin/posts"))
end
end
else
# Creating new post
form_data = %{
"title" => "",
"sub_title" => "",
"type" => "post",
"status" => Settings.get_setting("posts_default_status", "draft"),
"slug" => ""
}
form = Component.to_form(form_data, as: :post)
socket
|> assign(:page_title, "New Post")
|> assign(:project_title, project_title)
|> assign(:post, %{uuid: nil, user_uuid: current_user.uuid})
|> assign(:form, form)
|> assign(:content, "")
|> assign(:current_user, current_user)
|> load_form_data()
end
{:ok, socket}
end
@impl true
def handle_event("validate", %{"post" => post_params}, socket) do
form = Component.to_form(post_params, as: :post)
{:noreply, assign(socket, :form, form)}
end
@impl true
def handle_event("save", %{"post" => post_params}, socket) do
# Merge content from assigns (stored separately from form)
content = socket.assigns[:live_content] || socket.assigns.content
post_params = Map.put(post_params, "content", content)
# Parse tags from content if auto-tagging is enabled
tags = PhoenixKitPosts.parse_hashtags(content)
# Generate slug if auto-slug is enabled and slug is empty
post_params = maybe_generate_slug(post_params)
# Get post_uuid - handle both struct and map cases
post_uuid = Map.get(socket.assigns.post, :uuid)
save_post(socket, post_uuid, post_params, tags)
end
@impl true
def handle_event("cancel", _params, socket) do
{:noreply, push_navigate(socket, to: Routes.path("/admin/posts"))}
end
@impl true
def handle_event("add_tag", %{"value" => tag_name}, socket) do
tag_name = String.trim(tag_name)
if tag_name != "" do
current_tags = socket.assigns.selected_tags
max_tags = String.to_integer(Settings.get_setting("posts_max_tags", "20"))
if length(current_tags) < max_tags and tag_name not in current_tags do
{:noreply, assign(socket, :selected_tags, [tag_name | current_tags])}
else
{:noreply, socket}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("remove_tag", %{"tag" => tag_name}, socket) do
current_tags = Enum.reject(socket.assigns.selected_tags, &(&1 == tag_name))
{:noreply, assign(socket, :selected_tags, current_tags)}
end
@impl true
def handle_event("open_featured_image_selector", _params, socket) do
{:noreply,
socket
|> assign(:show_media_selector, true)
|> assign(:selecting_featured_image, true)
|> assign(:media_selector_mode, :multiple)}
end
@impl true
def handle_event("remove_post_image", %{"id" => media_uuid}, socket) do
post_uuid = Map.get(socket.assigns.post, :uuid)
if post_uuid do
# Existing post - remove from database
PhoenixKitPosts.detach_media_by_uuid(media_uuid)
post_images = PhoenixKitPosts.list_post_media(post_uuid, preload: [:file])
{:noreply, assign(socket, :post_images, post_images)}
else
# New post - remove from temporary list
post_images =
Enum.reject(socket.assigns.post_images, fn img ->
to_string(img.file_uuid) == media_uuid || to_string(img[:uuid]) == media_uuid
end)
pending_ids = Enum.reject(socket.assigns[:pending_image_uuids] || [], &(&1 == media_uuid))
{:noreply,
socket |> assign(:post_images, post_images) |> assign(:pending_image_uuids, pending_ids)}
end
end
@impl true
def handle_event("reorder_post_images", %{"ordered_ids" => ordered_ids}, socket) do
post_uuid = Map.get(socket.assigns.post, :uuid)
if post_uuid do
# Existing post - update positions in database
positions =
ordered_ids
|> Enum.with_index(1)
|> Map.new(fn {file_uuid, position} -> {file_uuid, position} end)
PhoenixKitPosts.reorder_media(post_uuid, positions)
# Reload images to reflect new order
post_images = PhoenixKitPosts.list_post_media(post_uuid, preload: [:file])
{:noreply, assign(socket, :post_images, post_images)}
else
# New post - reorder in memory
reordered =
ordered_ids
|> Enum.with_index(1)
|> Enum.map(fn {file_uuid, position} ->
img = Enum.find(socket.assigns.post_images, &(to_string(&1.file_uuid) == file_uuid))
%{img | position: position}
end)
|> Enum.reject(&is_nil/1)
{:noreply, assign(socket, :post_images, reordered)}
end
end
# Handle content changes from MarkdownEditor/Leaf component
@impl true
def handle_info(
{:editor_content_changed, %{content: content, editor_id: "post-content-editor" <> _}},
socket
) do
{:noreply, assign(socket, :live_content, content)}
end
# Handle image/video insert from MarkdownEditor toolbar
def handle_info({:editor_insert_component, %{type: type}}, socket)
when type in [:image, :video] do
do_insert_component(socket, type)
end
# Handle media selection from MediaSelectorModal
def handle_info({:media_selected, file_uuids}, socket) do
socket =
cond do
# Post images selection (supports multiple files)
not Enum.empty?(file_uuids) && socket.assigns.selecting_featured_image ->
post_uuid = Map.get(socket.assigns.post, :uuid)
if post_uuid do
# Existing post - save all selected images to database
# Get current max position
current_images = socket.assigns.post_images
max_position =
Enum.reduce(current_images, 0, fn img, acc -> max(acc, img.position) end)
# Add new images with incremental positions
file_uuids
|> Enum.with_index(max_position + 1)
|> Enum.each(fn {file_uuid, position} ->
PhoenixKitPosts.attach_media(post_uuid, file_uuid, position: position)
end)
# Reload all images
post_images = PhoenixKitPosts.list_post_media(post_uuid, preload: [:file])
socket
|> assign(:post_images, post_images)
|> assign(:show_media_selector, false)
|> assign(:selecting_featured_image, false)
else
# New post - store file_uuids temporarily until post is saved
# Create temporary structs for display
current_images = socket.assigns[:post_images] || []
max_position =
Enum.reduce(current_images, 0, fn img, acc -> max(acc, img.position) end)
new_images =
file_uuids
|> Enum.with_index(max_position + 1)
|> Enum.map(fn {file_uuid, position} ->
%{file_uuid: file_uuid, file: nil, position: position, id: nil}
end)
socket
|> assign(:post_images, current_images ++ new_images)
|> assign(
:pending_image_uuids,
(socket.assigns[:pending_image_uuids] || []) ++ file_uuids
)
|> assign(:show_media_selector, false)
|> assign(:selecting_featured_image, false)
end
# Content image insertion (supports multiple files)
not Enum.empty?(file_uuids) && socket.assigns.inserting_media_type ->
media_type = socket.assigns.inserting_media_type
# Build structured media list for client-side insertion
media_items =
Enum.map(file_uuids, fn fid ->
%{url: get_file_url(fid), type: media_type}
end)
socket
|> assign(:show_media_selector, false)
|> assign(:inserting_media_type, nil)
|> push_event("insert-media", %{items: media_items})
true ->
assign(socket, :show_media_selector, false)
end
{:noreply, socket}
end
# Handle media selector modal closed
def handle_info({:media_selector_closed}, socket) do
{:noreply,
socket
|> assign(:show_media_selector, false)
|> assign(:inserting_media_type, nil)
|> assign(:selecting_featured_image, false)}
end
# Handle Leaf editor messages
@impl true
def handle_info({:leaf_changed, %{markdown: content}}, socket) do
{:noreply, assign(socket, :live_content, content)}
end
@impl true
def handle_info({:leaf_insert_request, %{type: type}}, socket) do
do_insert_component(socket, type)
end
@impl true
def handle_info({:leaf_mode_changed, _}, socket), do: {:noreply, socket}
# Catch-all for other editor events
def handle_info({:editor_insert_component, _}, socket), do: {:noreply, socket}
def handle_info({:editor_save_requested, _}, socket), do: {:noreply, socket}
defp do_insert_component(socket, type) when type in [:image, :video] do
{:noreply,
socket
|> assign(:show_media_selector, true)
|> assign(:inserting_media_type, type)
|> assign(:media_selector_mode, :multiple)}
end
defp do_insert_component(socket, _type), do: {:noreply, socket}
## --- Private Helper Functions ---
# Get a URL for a file from storage (for standard markdown image syntax)
defp get_file_url(file_uuid) do
# Use "original" variant for the full-size image
URLSigner.signed_url(file_uuid, "original")
end
# Get URL for featured image (used in template)
def get_featured_image_url(nil), do: nil
def get_featured_image_url(%{file: %{uuid: file_uuid}}) when not is_nil(file_uuid) do
get_file_url(file_uuid)
end
def get_featured_image_url(%{file_uuid: file_uuid}) when not is_nil(file_uuid) do
get_file_url(file_uuid)
end
def get_featured_image_url(_), do: nil
defp save_post(socket, nil, post_params, tags) do
# Convert scheduled_at from user's local time to UTC
post_params = convert_scheduled_at_to_utc(post_params, socket.assigns.current_user)
new_status = post_params["status"]
# Creating new post
# First create with draft status if scheduling, then schedule separately
create_params =
if new_status == "scheduled" do
Map.put(post_params, "status", "draft")
else
post_params
end
try do
case PhoenixKitPosts.create_post(socket.assigns.current_user.uuid, create_params) do
{:ok, post} ->
# If scheduling, create the scheduled job
post =
if new_status == "scheduled" and post_params["scheduled_at"] do
case PhoenixKitPosts.schedule_post(post, post_params["scheduled_at"]) do
{:ok, scheduled_post} -> scheduled_post
{:error, _reason} -> post
end
else
post
end
# Handle tags
if tags != [] do
PhoenixKitPosts.add_tags_to_post(post, tags)
end
# Handle pending images (set during new post creation)
pending_ids = socket.assigns[:pending_image_uuids] || []
pending_ids
|> Enum.with_index(1)
|> Enum.each(fn {file_uuid, position} ->
PhoenixKitPosts.attach_media(post.uuid, file_uuid, position: position)
end)
{:noreply,
socket
|> put_flash(:info, "Post created successfully")
|> push_navigate(to: Routes.path("/admin/posts/#{post.uuid}"))}
{:error, _changeset} ->
{:noreply,
socket
|> put_flash(:error, "Failed to create post")
|> assign(:form, Component.to_form(post_params, as: :post))}
end
rescue
e ->
require Logger
Logger.error("Post save failed: #{Exception.message(e)}")
{:noreply, put_flash(socket, :error, "Something went wrong. Please try again.")}
end
end
defp save_post(socket, _post_uuid, post_params, tags) do
# Convert scheduled_at from user's local time to UTC
post_params = convert_scheduled_at_to_utc(post_params, socket.assigns.current_user)
post = socket.assigns.post
new_status = post_params["status"]
old_status = post.status
result =
cond do
# Scheduling a post (new status is "scheduled" with a datetime)
new_status == "scheduled" and post_params["scheduled_at"] ->
scheduled_at = post_params["scheduled_at"]
# Pass all other params to schedule_post (it will set status and scheduled_at)
other_params =
post_params
|> Map.delete("scheduled_at")
|> Map.delete("status")
PhoenixKitPosts.schedule_post(post, scheduled_at, other_params)
# Unscheduling a post (was scheduled, now something else)
old_status == "scheduled" and new_status != "scheduled" ->
case PhoenixKitPosts.unschedule_post(post) do
{:ok, unscheduled_post} ->
# Apply the new status/other changes
PhoenixKitPosts.update_post(unscheduled_post, post_params)
error ->
error
end
# Regular update (not involving scheduling)
true ->
PhoenixKitPosts.update_post(post, post_params)
end
case result do
{:ok, saved_post} ->
# Handle tags
if tags != [] do
PhoenixKitPosts.add_tags_to_post(saved_post, tags)
end
{:noreply,
socket
|> put_flash(:info, "Post updated successfully")
|> push_navigate(to: Routes.path("/admin/posts/#{saved_post.uuid}"))}
{:error, _error} ->
{:noreply,
socket
|> put_flash(:error, "Failed to update post")
|> assign(:form, Component.to_form(post_params, as: :post))}
end
rescue
e ->
require Logger
Logger.error("Post save failed: #{Exception.message(e)}")
{:noreply, put_flash(socket, :error, gettext("Something went wrong. Please try again."))}
end
defp load_form_data(socket) do
# Load user's groups for selection
user_groups = PhoenixKitPosts.list_user_groups(socket.assigns.current_user.uuid)
# Load existing tags if editing
selected_tags =
if Map.get(socket.assigns.post, :uuid) do
Enum.map(Map.get(socket.assigns.post, :tags, []) || [], & &1.name)
else
[]
end
# Load existing groups if editing
selected_groups =
if Map.get(socket.assigns.post, :uuid) do
Enum.map(Map.get(socket.assigns.post, :groups, []) || [], & &1.uuid)
else
[]
end
# Load settings
max_media = String.to_integer(Settings.get_setting("posts_max_media", "10"))
max_title_length = String.to_integer(Settings.get_setting("posts_max_title_length", "255"))
max_subtitle_length =
String.to_integer(Settings.get_setting("posts_max_subtitle_length", "500"))
max_content_length =
String.to_integer(Settings.get_setting("posts_max_content_length", "50000"))
max_tags = String.to_integer(Settings.get_setting("posts_max_tags", "20"))
default_status = Settings.get_setting("posts_default_status", "draft")
allow_scheduling = Settings.get_setting("posts_allow_scheduling", "true") == "true"
allow_groups = Settings.get_setting("posts_allow_groups", "true") == "true"
seo_auto_slug = Settings.get_setting("posts_seo_auto_slug", "true") == "true"
socket
|> assign(:user_groups, user_groups)
|> assign(:selected_tags, selected_tags)
|> assign(:selected_groups, selected_groups)
|> assign(:max_media, max_media)
|> assign(:max_title_length, max_title_length)
|> assign(:max_subtitle_length, max_subtitle_length)
|> assign(:max_content_length, max_content_length)
|> assign(:max_tags, max_tags)
|> assign(:default_status, default_status)
|> assign(:allow_scheduling, allow_scheduling)
|> assign(:allow_groups, allow_groups)
|> assign(:seo_auto_slug, seo_auto_slug)
|> assign(:show_media_selector, false)
|> assign(:inserting_media_type, nil)
|> assign(:selecting_featured_image, false)
|> assign(:media_selector_mode, :single)
|> load_post_images()
end
defp load_post_images(socket) do
post_uuid = Map.get(socket.assigns.post, :uuid)
post_images =
if post_uuid do
PhoenixKitPosts.list_post_media(post_uuid, preload: [:file])
else
[]
end
assign(socket, :post_images, post_images)
end
defp can_edit_post?(user, post) do
# User can edit if they own the post or are admin/owner
Map.get(post, :user_uuid) == user.uuid or user_is_admin?(user)
end
defp user_is_admin?(user) do
# Check if user has admin or owner role
Roles.user_has_role_owner?(user) or Roles.user_has_role_admin?(user)
end
defp maybe_generate_slug(post_params) do
seo_auto_slug = Settings.get_setting("posts_seo_auto_slug", "true") == "true"
slug = Map.get(post_params, "slug", "")
title = Map.get(post_params, "title", "")
if seo_auto_slug and (slug == "" or is_nil(slug)) and title != "" do
# Generate slug from title
generated_slug =
title
|> String.downcase()
|> String.replace(~r/[^a-z0-9\s-]/, "")
|> String.replace(~r/\s+/, "-")
|> String.replace(~r/-+/, "-")
|> String.trim("-")
Map.put(post_params, "slug", generated_slug)
else
post_params
end
end
# Format datetime for HTML datetime-local input (YYYY-MM-DDTHH:MM)
# Converts from UTC to user's local timezone for display
defp format_datetime_local(nil, _user), do: nil
defp format_datetime_local(%DateTime{} = dt, user) do
# Shift from UTC to user's timezone for display
local_dt = shift_to_user_timezone(dt, user)
Calendar.strftime(local_dt, "%Y-%m-%dT%H:%M")
end
defp format_datetime_local(%NaiveDateTime{} = dt, user) do
utc_dt = DateTime.from_naive!(dt, "Etc/UTC")
format_datetime_local(utc_dt, user)
end
defp format_datetime_local(_, _user), do: nil
# Convert scheduled_at from user's local time to UTC when saving
defp convert_scheduled_at_to_utc(post_params, user) do
case Map.get(post_params, "scheduled_at") do
nil ->
post_params
"" ->
post_params
local_time_str when is_binary(local_time_str) ->
# datetime-local gives us "YYYY-MM-DDTHH:MM", need to add seconds
case NaiveDateTime.from_iso8601(local_time_str <> ":00") do
{:ok, naive_dt} ->
utc_dt = shift_from_user_timezone(naive_dt, user)
Map.put(post_params, "scheduled_at", utc_dt)
_ ->
post_params
end
_ ->
# Already a DateTime, pass through
post_params
end
end
# Shift datetime from user's local timezone to UTC
defp shift_from_user_timezone(naive_dt, user) do
offset_str = (user && user.user_timezone) || "0"
case Integer.parse(offset_str) do
{offset_hours, _} ->
# Convert to UTC by subtracting the offset (local - offset = UTC)
utc_naive = NaiveDateTime.add(naive_dt, -offset_hours * 3600, :second)
DateTime.from_naive!(utc_naive, "Etc/UTC")
_ ->
DateTime.from_naive!(naive_dt, "Etc/UTC")
end
end
# Shift datetime from UTC to user's local timezone
defp shift_to_user_timezone(datetime, user) do
offset_str = (user && user.user_timezone) || "0"
case Integer.parse(offset_str) do
{offset_hours, _} ->
# Convert from UTC to local by adding the offset
DateTime.add(datetime, offset_hours * 3600, :second)
_ ->
datetime
end
end
end