Current section

Files

Jump to
phoenix_kit_publishing lib phoenix_kit_publishing web new.ex
Raw

lib/phoenix_kit_publishing/web/new.ex

defmodule PhoenixKit.Modules.Publishing.Web.New do
@moduledoc """
LiveView for creating a new publishing group.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
require Logger
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.Shared
alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
# Preset types with their default item names
@preset_types Publishing.preset_types()
@impl true
def mount(_params, _session, socket) do
{initial_params, auto_slug?, last_generated_slug, auto_item_names?} =
normalize_form_params(%{}, true, "", true)
socket =
socket
|> assign(:project_title, Settings.get_project_title())
|> assign(:page_title, gettext("Create Publishing Group"))
|> assign(
:current_path,
Routes.path("/admin/publishing/new-group")
)
|> assign(:slug_autogenerated?, auto_slug?)
|> assign(:last_generated_slug, last_generated_slug)
|> assign(:item_names_autogenerated?, auto_item_names?)
|> assign(:form_params, initial_params)
|> assign(:form, new_group_form(initial_params))
|> assign(:preset_types, @preset_types)
|> assign(:enabled_languages, Publishing.enabled_language_codes())
|> assign(:default_url_language, Publishing.get_primary_language_base())
|> assign(:endpoint_url, nil)
{:ok, socket}
end
@impl true
def handle_params(_params, uri, socket) do
endpoint_url = extract_endpoint_url(uri)
{:noreply, assign(socket, :endpoint_url, endpoint_url)}
end
@impl true
def handle_event("update_new_group", %{"group" => params}, socket) do
{normalized_params, auto_slug?, last_generated_slug, auto_item_names?} =
normalize_form_params(
params,
socket.assigns.slug_autogenerated?,
socket.assigns.last_generated_slug,
socket.assigns.item_names_autogenerated?
)
{:noreply,
socket
|> assign(:slug_autogenerated?, auto_slug?)
|> assign(:last_generated_slug, last_generated_slug)
|> assign(:item_names_autogenerated?, auto_item_names?)
|> assign(:form_params, normalized_params)
|> assign(:form, new_group_form(normalized_params))}
end
def handle_event("manual_slug", %{"group" => %{"slug" => value}}, socket) do
current_params = socket.assigns.form_params
name = Map.get(current_params, "name", "")
auto_slug = if name == "", do: "", else: Publishing.slugify(name)
trimmed_value = value |> to_string() |> String.trim()
cond do
# Empty slug - revert to auto-generated
trimmed_value == "" ->
updated = Map.put(current_params, "slug", auto_slug)
{:noreply,
socket
|> assign(:slug_autogenerated?, true)
|> assign(:last_generated_slug, auto_slug)
|> assign(:form_params, updated)
|> assign(:form, new_group_form(updated))}
# Manually entered slug matches auto-generated - keep autogenerated flag
trimmed_value == auto_slug ->
updated = Map.put(current_params, "slug", auto_slug)
{:noreply,
socket
|> assign(:slug_autogenerated?, true)
|> assign(:last_generated_slug, auto_slug)
|> assign(:form_params, updated)
|> assign(:form, new_group_form(updated))}
# Any other manual slug - just store it as-is (validation happens on submit)
true ->
updated = Map.put(current_params, "slug", trimmed_value)
{:noreply,
socket
|> assign(:slug_autogenerated?, false)
|> assign(:last_generated_slug, trimmed_value)
|> assign(:form_params, updated)
|> assign(:form, new_group_form(updated))
|> clear_flash()}
end
end
def handle_event("add_group", %{"group" => params}, socket) do
# Use raw params for validation - don't normalize/sanitize
name = params |> Map.get("name", "") |> String.trim()
mode = params |> Map.get("mode", "timestamp")
slug = params |> Map.get("slug", "") |> String.trim()
custom_type = params |> Map.get("custom_type", "") |> String.trim()
type =
if custom_type != "" do
custom_type
else
params |> Map.get("type", "blog")
end
item_singular = params |> Map.get("item_singular", "") |> String.trim()
item_plural = params |> Map.get("item_plural", "") |> String.trim()
# Still normalize for UI updates in case of error
{normalized_params, auto_slug?, last_generated_slug, auto_item_names?} =
normalize_form_params(
params,
socket.assigns.slug_autogenerated?,
socket.assigns.last_generated_slug,
socket.assigns.item_names_autogenerated?
)
# Pass nil if slug is empty to trigger auto-generation
slug_to_validate = if slug == "", do: nil, else: slug
# Build options for add_group
opts =
[
mode: mode,
slug: slug_to_validate,
type: type,
actor_uuid: Shared.actor_uuid_from_socket(socket)
]
|> maybe_add_opt(:item_singular, item_singular)
|> maybe_add_opt(:item_plural, item_plural)
case Publishing.add_group(name, opts) do
{:ok, group} ->
{:noreply,
socket
|> put_flash(:info, gettext("Publishing group \"%{name}\" created", name: group["name"]))
|> push_navigate(to: Routes.path("/admin/publishing/#{group["slug"]}"))}
{:error, :already_exists} ->
{:noreply,
socket
|> assign_form_state(
normalized_params,
auto_slug?,
last_generated_slug,
auto_item_names?
)
|> put_flash(:error, gettext("That publishing group already exists"))}
{:error, :invalid_name} ->
{:noreply,
socket
|> assign_form_state(
normalized_params,
auto_slug?,
last_generated_slug,
auto_item_names?
)
|> put_flash(:error, gettext("Please enter a valid name"))}
{:error, :invalid_mode} ->
{:noreply,
socket
|> assign_form_state(
normalized_params,
auto_slug?,
last_generated_slug,
auto_item_names?
)
|> put_flash(:error, gettext("Invalid URL mode"))}
{:error, :invalid_type} ->
{:noreply,
socket
|> assign_form_state(
normalized_params,
auto_slug?,
last_generated_slug,
auto_item_names?
)
|> put_flash(:error, gettext("Invalid content type"))}
{:error, :invalid_slug} ->
{:noreply,
socket
|> assign_form_state(
normalized_params,
auto_slug?,
last_generated_slug,
auto_item_names?
)
|> put_flash(
:error,
gettext(
"Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-group-name)"
)
)}
end
end
# When type changes via radio button click, reset item names to auto-fill mode
def handle_event("type_changed", %{"type" => new_type}, socket) do
current_params = socket.assigns.form_params
{default_singular, default_plural} = get_default_item_names(new_type)
updated_params =
current_params
|> Map.put("type", new_type)
|> Map.put("item_singular", default_singular)
|> Map.put("item_plural", default_plural)
{:noreply,
socket
|> assign(:item_names_autogenerated?, true)
|> assign(:form_params, updated_params)
|> assign(:form, new_group_form(updated_params))}
end
# Also handle when type comes through the form
def handle_event("type_changed", %{"group" => %{"type" => new_type}}, socket) do
handle_event("type_changed", %{"type" => new_type}, socket)
end
# When user manually edits item names, disable auto-fill
def handle_event("item_name_changed", _params, socket) do
{:noreply, assign(socket, :item_names_autogenerated?, false)}
end
def handle_event("cancel", _params, socket) do
{:noreply, push_navigate(socket, to: Routes.path("/admin/publishing"))}
end
@impl true
def handle_info(msg, socket) do
Logger.debug("[Publishing.Web.New] unhandled message: #{inspect(msg)}")
{:noreply, socket}
end
# Helper to assign form state after an error
defp assign_form_state(socket, params, auto_slug?, last_generated_slug, auto_item_names?) do
socket
|> assign(:slug_autogenerated?, auto_slug?)
|> assign(:last_generated_slug, last_generated_slug)
|> assign(:item_names_autogenerated?, auto_item_names?)
|> assign(:form_params, params)
|> assign(:form, new_group_form(params))
end
# Helper to add optional params only if non-empty
defp maybe_add_opt(opts, _key, ""), do: opts
defp maybe_add_opt(opts, key, value), do: Keyword.put(opts, key, value)
defp new_group_form(params) when is_map(params) do
defaults = %{
"name" => "",
"slug" => "",
"mode" => "timestamp",
"type" => "blog",
"item_singular" => "post",
"item_plural" => "posts"
}
values = Map.merge(defaults, params)
to_form(values, as: :group)
end
# Normalize form params with auto-fill behavior for slug and item names
defp normalize_form_params(
params,
slug_autogenerated?,
last_generated_slug,
item_names_autogenerated?
) do
name = normalize_name(params)
mode = normalize_mode(params)
type = normalize_type(params)
{slug_value, new_auto_slug?, new_last_generated} =
normalize_slug(params, name, slug_autogenerated?, last_generated_slug)
{item_singular, item_plural, new_auto_item_names?} =
normalize_item_names(params, type, item_names_autogenerated?)
result_params = %{
"name" => name,
"slug" => slug_value,
"mode" => mode,
"type" => type,
"item_singular" => item_singular,
"item_plural" => item_plural
}
{result_params, new_auto_slug?, new_last_generated, new_auto_item_names?}
end
defp normalize_name(params) do
params
|> Map.get("name", "")
|> to_string()
|> String.trim()
end
defp normalize_mode(params) do
params
|> Map.get("mode", "timestamp")
|> to_string()
|> String.downcase()
|> case do
"slug" -> "slug"
_ -> "timestamp"
end
end
defp normalize_type(params) do
custom_type = params |> Map.get("custom_type", "") |> to_string() |> String.trim()
if custom_type != "" do
String.downcase(custom_type)
else
params
|> Map.get("type", "blog")
|> to_string()
|> String.downcase()
end
end
defp normalize_slug(params, name, slug_autogenerated?, last_generated_slug) do
trimmed_slug = params |> Map.get("slug", "") |> to_string() |> String.trim()
auto_slug = if name == "", do: "", else: Publishing.slugify(name)
if slug_autogenerated? do
{auto_slug, true, auto_slug}
else
slug_val = if trimmed_slug == "", do: auto_slug, else: trimmed_slug
auto? = slug_val == auto_slug
new_last = if auto?, do: slug_val, else: last_generated_slug
{slug_val, auto?, new_last}
end
end
defp normalize_item_names(params, type, item_names_autogenerated?) do
{default_singular, default_plural} = get_default_item_names(type)
item_singular_input = params |> Map.get("item_singular", "") |> to_string() |> String.trim()
item_plural_input = params |> Map.get("item_plural", "") |> to_string() |> String.trim()
if item_names_autogenerated? do
{default_singular, default_plural, true}
else
singular = if item_singular_input == "", do: default_singular, else: item_singular_input
plural = if item_plural_input == "", do: default_plural, else: item_plural_input
auto? = singular == default_singular and plural == default_plural
{singular, plural, auto?}
end
end
# Get default item names for a content type
defp get_default_item_names(type) do
case Enum.find(@preset_types, fn p -> p.type == type end) do
%{item_singular: singular, item_plural: plural} -> {singular, plural}
nil -> {"item", "items"}
end
end
defp extract_endpoint_url(uri) when is_binary(uri) do
case URI.parse(uri) do
%URI{scheme: scheme, host: host, port: port} when not is_nil(scheme) and not is_nil(host) ->
port_string = if port in [80, 443], do: "", else: ":#{port}"
"#{scheme}://#{host}#{port_string}"
_ ->
""
end
end
defp extract_endpoint_url(_), do: ""
@impl true
def render(assigns) do
~H"""
<div class="container flex flex-col mx-auto px-4 py-6">
<%!-- Header Section --%>
<.admin_page_header
back={Routes.path("/admin/publishing")}
title={gettext("Create a New Publishing Group")}
/>
<div class="max-w-2xl mx-auto space-y-6">
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body space-y-6">
<.form
for={@form}
id="group-create-form"
class="space-y-6"
phx-change="update_new_group"
phx-submit="add_group"
>
<div class="space-y-2">
<.input
field={@form[:name]}
type="text"
label={gettext("Group Name")}
placeholder={gettext("e.g. Product Updates")}
required
/>
<p class="text-xs text-base-content/60">
{gettext("Used for display in the navigation and admin sections.")}
</p>
</div>
<div class="space-y-2">
<.input
field={@form[:slug]}
type="text"
label={gettext("Group Slug")}
placeholder={gettext("e.g. product-updates")}
required
phx-change="manual_slug"
phx-debounce="150"
/>
<div class="space-y-1">
<p class="text-xs text-base-content/60">
{gettext("Used for the URL and as the unique identifier for this group.")}
</p>
<p class="text-xs font-medium text-base-content/70">
<span class="font-semibold">{gettext("Format")}:</span>
{gettext(
"Only lowercase letters (a-z), numbers (0-9), and hyphens (-) are allowed. Must not start or end with a hyphen."
)}
</p>
<p class="text-xs text-success">
âś“ {gettext("Valid examples")}: <code class="font-mono">blog</code>, <code class="font-mono">product-updates</code>,
<code class="font-mono">news-2025</code>
</p>
<p class="text-xs text-error">
âś— {gettext("Invalid examples")}: <code class="font-mono">Blog</code>, <code class="font-mono">product_updates</code>, <code class="font-mono">-news</code>,
<code class="font-mono">my blog</code>
</p>
</div>
<% slug_value = @form[:slug].value || "" %>
<% slug_sample = if slug_value == "", do: gettext("your-group"), else: slug_value %>
<% group_base_url =
@endpoint_url <>
PublishingHTML.group_listing_path(@default_url_language, slug_sample) %>
<% timestamp_url_pattern = group_base_url <> "/<YYYY-MM-DD>/<HH:mm>" %>
<% slug_url_pattern = group_base_url <> "/:post_slug" %>
</div>
<%!-- Content Type Section --%>
<div class="form-control">
<label class="label">
<span class="label-text text-sm font-semibold text-base-content">
{gettext("Content Type")}
</span>
</label>
<div class="space-y-3">
<%= for preset <- @preset_types do %>
<label class="cursor-pointer flex items-start gap-3 rounded-lg border border-base-300/70 bg-base-200/40 px-4 py-3 hover:border-primary/60 transition">
<input
type="radio"
name={@form[:type].name}
value={preset.type}
class="radio radio-primary mt-1"
checked={@form[:type].value == preset.type}
phx-click="type_changed"
phx-value-type={preset.type}
/>
<div>
<p class="font-medium text-base-content">{preset.label}</p>
<p class="text-sm text-base-content/70">
<%= case preset.type do %>
<% "blog" -> %>
{gettext(
"Posts, news articles, and updates. Items called \"%{plural}\".",
plural: preset.item_plural
)}
<% "faq" -> %>
{gettext("Frequently asked questions. Items called \"%{plural}\".",
plural: preset.item_plural
)}
<% "legal" -> %>
{gettext(
"Legal pages like terms and privacy policy. Items called \"%{plural}\".",
plural: preset.item_plural
)}
<% _ -> %>
{gettext("Items called \"%{plural}\".", plural: preset.item_plural)}
<% end %>
</p>
</div>
</label>
<% end %>
<%!-- Custom Type Option --%>
<label class="cursor-pointer flex items-start gap-3 rounded-lg border border-base-300/70 bg-base-200/40 px-4 py-3 hover:border-primary/60 transition">
<input
type="radio"
name={@form[:type].name}
value="custom"
class="radio radio-primary mt-1"
checked={
@form[:type].value not in PhoenixKit.Modules.Publishing.Constants.preset_types()
}
phx-click="type_changed"
phx-value-type="custom"
/>
<div class="flex-1">
<p class="font-medium text-base-content">{gettext("Custom")}</p>
<p class="text-sm text-base-content/70">
{gettext("Define your own content type and item names.")}
</p>
<%= if @form[:type].value not in PhoenixKit.Modules.Publishing.Constants.preset_types() do %>
<div class="mt-2">
<input
type="text"
name="group[custom_type]"
class="input input-bordered input-sm w-full max-w-xs"
placeholder={gettext("e.g. tutorials, recipes")}
value={
if @form[:type].value not in PhoenixKit.Modules.Publishing.Constants.valid_types(),
do: @form[:type].value,
else: ""
}
phx-change="update_new_group"
phx-debounce="300"
/>
</div>
<% end %>
</div>
</label>
</div>
<label class="label">
<span class="label-text-alt text-xs text-base-content/60">
{gettext(
"Content type determines default item naming and may affect how content is displayed."
)}
</span>
</label>
</div>
<%!-- Item Naming Section --%>
<div class="form-control">
<label class="label">
<span class="label-text text-sm font-semibold text-base-content">
{gettext("Item Naming")}
</span>
</label>
<p class="text-xs text-base-content/60 mb-3">
{gettext(
"What do you call items in this publishing group? These names appear in the UI."
)}
</p>
<div class="grid grid-cols-2 gap-4">
<div>
<.input
field={@form[:item_singular]}
type="text"
label={gettext("Singular")}
placeholder={gettext("e.g. post, question")}
phx-focus="item_name_changed"
/>
</div>
<div>
<.input
field={@form[:item_plural]}
type="text"
label={gettext("Plural")}
placeholder={gettext("e.g. posts, questions")}
phx-focus="item_name_changed"
/>
</div>
</div>
<label class="label">
<span class="label-text-alt text-xs text-base-content/60">
{gettext("Examples: \"Create new %{singular}\", \"View all %{plural}\"",
singular: @form[:item_singular].value || "item",
plural: @form[:item_plural].value || "items"
)}
</span>
</label>
</div>
<%!-- URL Mode Section --%>
<div class="form-control">
<label class="label">
<span class="label-text text-sm font-semibold text-base-content">
{gettext("URL Mode")}
</span>
</label>
<div class="space-y-3">
<label class="cursor-pointer flex items-start gap-3 rounded-lg border border-base-300/70 bg-base-200/40 px-4 py-3 hover:border-primary/60 transition">
<input
type="radio"
name={@form[:mode].name}
value="timestamp"
class="radio radio-primary mt-1"
checked={@form[:mode].value == "timestamp"}
/>
<div>
<p class="font-medium text-base-content">{gettext("Timestamp-based")}</p>
<p class="text-sm text-base-content/70">
{gettext(
"Organizes posts by date/time in URLs. Great for news, updates, and chronological feeds."
)}
</p>
<div class="mt-2 space-y-1 text-xs text-base-content/70">
<p>
<span class="font-medium text-base-content">
{gettext("Public URL")}:
</span>
<code class="font-mono break-all">{timestamp_url_pattern}</code>
</p>
</div>
</div>
</label>
<label class="cursor-pointer flex items-start gap-3 rounded-lg border border-base-300/70 bg-base-200/40 px-4 py-3 hover:border-primary/60 transition">
<input
type="radio"
name={@form[:mode].name}
value="slug"
class="radio radio-primary mt-1"
checked={@form[:mode].value == "slug"}
/>
<div>
<p class="font-medium text-base-content">{gettext("Slug-based")}</p>
<p class="text-sm text-base-content/70">
{gettext(
"Uses semantic URLs and editable slugs. Ideal for documentation, guides, and evergreen content."
)}
</p>
<div class="mt-2 space-y-1 text-xs text-base-content/70">
<p>
<span class="font-medium text-base-content">
{gettext("Public URL")}:
</span>
<code class="font-mono break-all">{slug_url_pattern}</code>
</p>
</div>
</div>
</label>
</div>
<label class="label">
<span class="label-text-alt text-xs text-base-content/60">
{gettext(
"The URL mode locks in after creation to keep your structure consistent."
)}
</span>
</label>
</div>
<div class="flex flex-wrap gap-3 justify-end">
<button
type="submit"
class="btn btn-primary btn-sm"
phx-disable-with={gettext("Creating…")}
>
<.icon name="hero-plus" class="w-4 h-4 mr-1" /> {gettext(
"Create Publishing Group"
)}
</button>
<button type="button" class="btn btn-ghost btn-sm" phx-click="cancel">
<.icon name="hero-arrow-uturn-left" class="w-4 h-4 mr-1" /> {gettext("Cancel")}
</button>
</div>
</.form>
</div>
</div>
</div>
</div>
"""
end
end