Packages

phoenix_kit

1.4.7
1.7.208 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 phoenix_kit_web live modules publishing editor.ex
Raw

lib/phoenix_kit_web/live/modules/publishing/editor.ex

defmodule PhoenixKitWeb.Live.Modules.Publishing.Editor do
@moduledoc """
Markdown editor for publishing entries.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKitWeb.Live.Modules.Publishing
alias PhoenixKitWeb.Live.Modules.Publishing.Storage
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
def mount(%{"type" => type_slug} = params, _session, socket) do
locale = params["locale"] || socket.assigns[:current_locale] || "en"
Gettext.put_locale(PhoenixKitWeb.Gettext, locale)
Process.put(:phoenix_kit_current_locale, locale)
socket =
socket
|> assign(:current_locale, locale)
|> assign(:project_title, Settings.get_setting("project_title", "PhoenixKit"))
|> assign(:page_title, "Publishing Editor")
|> assign(:type_slug, type_slug)
{:ok, socket}
end
def handle_params(%{"new" => "true"}, _uri, socket) do
type_slug = socket.assigns.type_slug
all_enabled_languages = Storage.enabled_language_codes()
primary_language = hd(all_enabled_languages)
# Create a virtual entry structure for a new post
now = DateTime.utc_now() |> DateTime.truncate(:second) |> floor_datetime_to_minute()
date = DateTime.to_date(now)
time = DateTime.to_time(now)
# Build the path where the file will be saved
time_folder =
"#{String.pad_leading(to_string(time.hour), 2, "0")}:#{String.pad_leading(to_string(time.minute), 2, "0")}"
virtual_path =
Path.join([type_slug, Date.to_iso8601(date), time_folder, "#{primary_language}.phk"])
virtual_entry = %{
type: type_slug,
date: date,
time: time,
path: virtual_path,
full_path: nil,
metadata: %{
title: "",
status: "draft",
published_at: DateTime.to_iso8601(now)
},
content: "",
language: primary_language,
available_languages: []
}
socket =
socket
|> assign(:entry, virtual_entry)
|> assign(:type_name, Publishing.type_name(type_slug) || type_slug)
|> assign(:form, entry_form(virtual_entry))
|> assign(:content, "")
|> assign(:current_language, primary_language)
|> assign(:available_languages, [])
|> assign(:all_enabled_languages, all_enabled_languages)
|> assign(
:current_path,
Routes.path("/admin/publishing/#{type_slug}/edit",
locale: socket.assigns.current_locale
)
)
|> assign(:has_pending_changes, false)
|> assign(:is_new_entry, true)
|> push_event("changes-status", %{has_changes: false})
{:noreply, socket}
end
def handle_params(%{"path" => path} = params, _uri, socket) do
type_slug = socket.assigns.type_slug
case Publishing.read_entry(type_slug, path) do
{:ok, entry} ->
# Get all enabled languages to show in switcher
all_enabled_languages = Storage.enabled_language_codes()
# Check if we should switch to a different language
switch_to_lang = Map.get(params, "switch_to")
socket =
if switch_to_lang && switch_to_lang not in entry.available_languages do
# Create virtual entry for the new language
new_path =
path
|> Path.dirname()
|> Path.join("#{switch_to_lang}.phk")
virtual_entry = %{
entry
| path: new_path,
language: switch_to_lang,
content: "",
metadata: Map.put(entry.metadata, :title, "")
}
socket
|> assign(:entry, virtual_entry)
|> assign(:type_name, Publishing.type_name(type_slug) || type_slug)
|> assign(:form, entry_form(virtual_entry))
|> assign(:content, "")
|> assign(:current_language, switch_to_lang)
|> assign(:available_languages, entry.available_languages)
|> assign(:all_enabled_languages, all_enabled_languages)
|> assign(
:current_path,
Routes.path("/admin/publishing/#{type_slug}/edit",
locale: socket.assigns.current_locale
)
)
|> assign(:has_pending_changes, false)
|> assign(:is_new_translation, true)
|> assign(:original_entry_path, path)
|> push_event("changes-status", %{has_changes: false})
else
socket
|> assign(:entry, entry)
|> assign(:type_name, Publishing.type_name(type_slug) || type_slug)
|> assign(:form, entry_form(entry))
|> assign(:content, entry.content)
|> assign(:current_language, entry.language)
|> assign(:available_languages, entry.available_languages)
|> assign(:all_enabled_languages, all_enabled_languages)
|> assign(
:current_path,
Routes.path("/admin/publishing/#{type_slug}/edit",
locale: socket.assigns.current_locale
)
)
|> assign(:has_pending_changes, false)
|> push_event("changes-status", %{has_changes: false})
end
{:noreply, socket}
{:error, _reason} ->
{:noreply,
socket
|> put_flash(:error, gettext("Entry not found"))
|> push_navigate(
to:
Routes.path("/admin/publishing/#{type_slug}", locale: socket.assigns.current_locale)
)}
end
end
def handle_event("update_meta", params, socket) do
params = Map.drop(params, ["_target"])
new_form =
socket.assigns.form
|> Map.merge(params)
|> normalize_form()
has_changes = dirty?(socket.assigns.entry, 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})}
end
def handle_event("update_content", %{"content" => content}, socket) do
has_changes = dirty?(socket.assigns.entry, socket.assigns.form, content)
socket =
socket
|> assign(:content, content)
|> assign(:has_pending_changes, has_changes)
# Notify JavaScript hook about changes status
{:noreply, push_event(socket, "changes-status", %{has_changes: has_changes})}
end
def handle_event("save", _params, %{assigns: %{has_pending_changes: false}} = socket) do
{:noreply, socket}
end
def handle_event("save", _params, socket) do
params =
socket.assigns.form
|> Map.take(["title", "status", "published_at"])
|> Map.put("content", socket.assigns.content)
is_new_entry = Map.get(socket.assigns, :is_new_entry, false)
is_new_translation = Map.get(socket.assigns, :is_new_translation, false)
cond do
is_new_entry ->
# Creating a completely new entry (from "Create Post" button)
case Publishing.create_entry(socket.assigns.type_slug) do
{:ok, new_entry} ->
# Now update it with the user's content
case Publishing.update_entry(socket.assigns.type_slug, new_entry, params) do
{:ok, updated_entry} ->
{:noreply,
socket
|> assign(:entry, updated_entry)
|> assign(:form, entry_form(updated_entry))
|> assign(:content, updated_entry.content)
|> assign(:available_languages, updated_entry.available_languages)
|> assign(:has_pending_changes, false)
|> assign(:is_new_entry, false)
|> push_event("changes-status", %{has_changes: false})
|> put_flash(:info, gettext("Entry created and saved"))
|> push_patch(
to:
Routes.path(
"/admin/publishing/#{socket.assigns.type_slug}/edit?path=#{URI.encode(updated_entry.path)}",
locale: socket.assigns.current_locale
)
)}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Failed to save entry"))}
end
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Failed to create entry"))}
end
is_new_translation ->
# Creating a new translation file
original_path = Map.get(socket.assigns, :original_entry_path, socket.assigns.entry.path)
case Publishing.add_language_to_entry(
socket.assigns.type_slug,
original_path,
socket.assigns.current_language
) do
{:ok, new_entry} ->
# Now update it with the user's content
case Publishing.update_entry(socket.assigns.type_slug, new_entry, params) do
{:ok, updated_entry} ->
{:noreply,
socket
|> assign(:entry, updated_entry)
|> assign(:form, entry_form(updated_entry))
|> assign(:content, updated_entry.content)
|> assign(:available_languages, updated_entry.available_languages)
|> assign(:has_pending_changes, false)
|> assign(:is_new_translation, false)
|> assign(:original_entry_path, nil)
|> push_event("changes-status", %{has_changes: false})
|> put_flash(:info, gettext("Translation created and saved"))
|> push_patch(
to:
Routes.path(
"/admin/publishing/#{socket.assigns.type_slug}/edit?path=#{URI.encode(updated_entry.path)}",
locale: socket.assigns.current_locale
)
)}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Failed to save translation"))}
end
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Failed to create translation file"))}
end
true ->
# Normal save for existing file
case Publishing.update_entry(socket.assigns.type_slug, socket.assigns.entry, params) do
{:ok, entry} ->
{:noreply,
socket
|> assign(:entry, entry)
|> assign(:form, entry_form(entry))
|> assign(:content, entry.content)
|> assign(:has_pending_changes, false)
|> push_event("changes-status", %{has_changes: false})
|> put_flash(:info, gettext("Entry saved"))}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Failed to save entry"))}
end
end
end
def handle_event("noop", _params, socket), do: {:noreply, socket}
def handle_event("preview", _params, socket) do
# Simple preview - just navigate to preview page
{:noreply,
push_navigate(socket,
to:
Routes.path(
"/admin/publishing/#{socket.assigns.type_slug}/preview?path=#{URI.encode(socket.assigns.entry.path)}",
locale: socket.assigns.current_locale
)
)}
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.type_slug}",
locale: socket.assigns.current_locale
)
)}
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
entry = socket.assigns.entry
type_slug = socket.assigns.type_slug
# Build path with new language
new_path =
entry.path
|> Path.dirname()
|> Path.join("#{new_language}.phk")
# Check if file exists
file_exists = new_language in entry.available_languages
if file_exists do
# File exists, just switch to it
{:noreply,
push_patch(socket,
to:
Routes.path(
"/admin/publishing/#{type_slug}/edit?path=#{URI.encode(new_path)}",
locale: socket.assigns.current_locale
)
)}
else
# File doesn't exist - create a virtual entry for editing
# It will be created when user saves
virtual_entry = %{
entry
| path: new_path,
language: new_language,
content: "",
metadata: Map.put(entry.metadata, :title, "")
}
socket =
socket
|> assign(:entry, virtual_entry)
|> assign(:form, entry_form(virtual_entry))
|> assign(:content, "")
|> assign(:current_language, new_language)
|> assign(:has_pending_changes, false)
|> assign(:is_new_translation, true)
|> assign(:original_entry_path, entry.path)
|> push_event("changes-status", %{has_changes: false})
{:noreply, socket}
end
end
defp entry_form(entry) do
%{
"title" => entry.metadata.title || "",
"status" => entry.metadata.status || "draft",
"published_at" =>
entry.metadata.published_at ||
DateTime.utc_now()
|> floor_datetime_to_minute()
|> DateTime.to_iso8601()
}
|> normalize_form()
end
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 floor_datetime_to_minute(%DateTime{} = datetime) do
%DateTime{datetime | second: 0, microsecond: {0, 0}}
end
defp dirty?(entry, form, content) do
normalized_form = normalize_form(form)
normalized_form != entry_form(entry) || content != entry.content
end
defp normalize_form(form) when is_map(form) do
%{
"title" => Map.get(form, "title", "") || "",
"status" => Map.get(form, "status", "draft") || "draft",
"published_at" => normalize_published_at(Map.get(form, "published_at"))
}
end
defp normalize_form(_), do: %{"title" => "", "status" => "draft", "published_at" => ""}
defp normalize_published_at(nil), do: ""
defp normalize_published_at(value) when is_binary(value) do
trimmed = String.trim(value)
cond do
trimmed == "" ->
""
# Handle datetime-local input format (YYYY-MM-DDTHH:MM)
String.length(trimmed) == 16 and String.contains?(trimmed, "T") ->
trimmed <> ":00Z"
# Parse and normalize ISO8601 datetime
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: ""
end