From 6f5bb8d4b9e245d1aee35498b241f6298b618019 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 17 Mar 2026 15:59:21 +0200 Subject: [PATCH 01/15] Add startup warning when PhoenixKit is not installed Logs a warning on application start if PhoenixKit is added as a dependency but not configured, directing users to run mix phoenix_kit.install. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/phoenix_kit/application.ex | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/phoenix_kit/application.ex b/lib/phoenix_kit/application.ex index 951a98aae..07f324695 100644 --- a/lib/phoenix_kit/application.ex +++ b/lib/phoenix_kit/application.ex @@ -7,10 +7,24 @@ defmodule PhoenixKit.Application do """ use Application + require Logger + @impl true def start(_type, _args) do + check_installation() + # PhoenixKit.Supervisor is started by parent app in its supervision tree # This is just a placeholder to satisfy OTP application callback Supervisor.start_link([], strategy: :one_for_one, name: PhoenixKit.AppSupervisor) end + + defp check_installation do + unless PhoenixKit.configured?() do + Logger.warning(""" + PhoenixKit is added as a dependency but not installed. + Run: mix phoenix_kit.install + See: https://hexdocs.pm/phoenix_kit + """) + end + end end From 366c62543268815c4098e28bcf0d948463cb5f0d Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 17 Mar 2026 17:35:28 +0200 Subject: [PATCH 02/15] Fix email template seeding failing on fresh install seed_system_templates passes plain strings for multilingual fields (display_name, subject, html_body, etc.) but the Template schema expects i18n maps (%{"en" => "value"}). Wrap string values in language maps before passing to create_template. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/emails/templates.ex | 425 +++++++++++++++++--------------- 1 file changed, 221 insertions(+), 204 deletions(-) diff --git a/lib/modules/emails/templates.ex b/lib/modules/emails/templates.ex index fb5029493..68089ed00 100644 --- a/lib/modules/emails/templates.ex +++ b/lib/modules/emails/templates.ex @@ -570,218 +570,220 @@ defmodule PhoenixKit.Modules.Emails.Templates do """ def seed_system_templates do - system_templates = [ - %{ - name: "magic_link", - slug: "magic-link", - display_name: "Magic Link Authentication", - description: "Secure login link email for passwordless authentication", - subject: "Your secure login link", - html_body: magic_link_html_template(), - text_body: magic_link_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "magic_link_url" => "URL for magic link authentication" - }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "register", - slug: "register", - display_name: "Account Confirmation", - description: "Email sent to confirm user registration", - subject: "Confirm your account", - html_body: register_html_template(), - text_body: register_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "confirmation_url" => "URL for account confirmation" + # Wrap string fields in language maps for multilingual schema compatibility + system_templates = + wrap_i18n_fields([ + %{ + name: "magic_link", + slug: "magic-link", + display_name: "Magic Link Authentication", + description: "Secure login link email for passwordless authentication", + subject: "Your secure login link", + html_body: magic_link_html_template(), + text_body: magic_link_text_template(), + category: "system", + status: "active", + is_system: true, + variables: %{ + "user_email" => "User's email address", + "magic_link_url" => "URL for magic link authentication" + }, + metadata: %{"source_module" => "users"} }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "reset_password", - slug: "reset-password", - display_name: "Password Reset", - description: "Email sent for password reset requests", - subject: "Reset your password", - html_body: reset_password_html_template(), - text_body: reset_password_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "reset_url" => "URL for password reset" + %{ + name: "register", + slug: "register", + display_name: "Account Confirmation", + description: "Email sent to confirm user registration", + subject: "Confirm your account", + html_body: register_html_template(), + text_body: register_text_template(), + category: "system", + status: "active", + is_system: true, + variables: %{ + "user_email" => "User's email address", + "confirmation_url" => "URL for account confirmation" + }, + metadata: %{"source_module" => "users"} }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "test_email", - slug: "test-email", - display_name: "Test Email", - description: "Test email for verifying email tracking system", - subject: "Test Tracking Email - {{timestamp}}", - html_body: test_email_html_template(), - text_body: test_email_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "recipient_email" => "Recipient's email address", - "timestamp" => "Current timestamp", - "test_link_url" => "URL for testing link tracking" + %{ + name: "reset_password", + slug: "reset-password", + display_name: "Password Reset", + description: "Email sent for password reset requests", + subject: "Reset your password", + html_body: reset_password_html_template(), + text_body: reset_password_text_template(), + category: "system", + status: "active", + is_system: true, + variables: %{ + "user_email" => "User's email address", + "reset_url" => "URL for password reset" + }, + metadata: %{"source_module" => "users"} }, - metadata: %{"source_module" => "admin"} - }, - %{ - name: "update_email", - slug: "update-email", - display_name: "Email Change Confirmation", - description: "Email sent to confirm email address changes", - subject: "Confirm your email change", - html_body: update_email_html_template(), - text_body: update_email_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "update_url" => "URL for email update confirmation" + %{ + name: "test_email", + slug: "test-email", + display_name: "Test Email", + description: "Test email for verifying email tracking system", + subject: "Test Tracking Email - {{timestamp}}", + html_body: test_email_html_template(), + text_body: test_email_text_template(), + category: "system", + status: "active", + is_system: true, + variables: %{ + "recipient_email" => "Recipient's email address", + "timestamp" => "Current timestamp", + "test_link_url" => "URL for testing link tracking" + }, + metadata: %{"source_module" => "admin"} }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "billing_invoice", - slug: "billing-invoice", - display_name: "Billing Invoice", - description: "Invoice email sent to customers for payment", - subject: "Invoice {{invoice_number}} - {{company_name}}", - html_body: billing_invoice_html_template(), - text_body: billing_invoice_text_template(), - category: "transactional", - status: "active", - is_system: true, - variables: %{ - "user_email" => "Customer's email address", - "user_name" => "Customer's name", - "invoice_number" => "Invoice number", - "invoice_date" => "Invoice date", - "due_date" => "Payment due date", - "subtotal" => "Subtotal amount", - "tax_amount" => "Tax amount", - "total" => "Total amount", - "currency" => "Currency code", - "line_items_html" => "HTML table of line items", - "line_items_text" => "Text list of line items", - "company_name" => "Company name", - "company_address" => "Company address", - "company_vat" => "Company VAT number", - "bank_name" => "Bank name", - "bank_iban" => "Bank IBAN", - "bank_swift" => "Bank SWIFT/BIC", - "payment_terms" => "Payment terms", - "invoice_url" => "URL to view invoice online" + %{ + name: "update_email", + slug: "update-email", + display_name: "Email Change Confirmation", + description: "Email sent to confirm email address changes", + subject: "Confirm your email change", + html_body: update_email_html_template(), + text_body: update_email_text_template(), + category: "system", + status: "active", + is_system: true, + variables: %{ + "user_email" => "User's email address", + "update_url" => "URL for email update confirmation" + }, + metadata: %{"source_module" => "users"} }, - metadata: %{"source_module" => "billing"} - }, - %{ - name: "billing_receipt", - slug: "billing-receipt", - display_name: "Billing Receipt", - description: "Receipt email sent to customers after payment confirmation", - subject: "Receipt {{receipt_number}} - {{company_name}}", - html_body: billing_receipt_html_template(), - text_body: billing_receipt_text_template(), - category: "transactional", - status: "active", - is_system: true, - variables: %{ - "user_email" => "Customer's email address", - "user_name" => "Customer's name", - "receipt_number" => "Receipt number", - "invoice_number" => "Original invoice number", - "payment_date" => "Date of payment", - "subtotal" => "Subtotal amount", - "tax_amount" => "Tax amount", - "total" => "Total amount", - "paid_amount" => "Amount paid", - "currency" => "Currency code", - "line_items_html" => "HTML table of line items", - "line_items_text" => "Text list of line items", - "company_name" => "Company name", - "company_address" => "Company address", - "company_vat" => "Company VAT number", - "receipt_url" => "URL to view receipt online" + %{ + name: "billing_invoice", + slug: "billing-invoice", + display_name: "Billing Invoice", + description: "Invoice email sent to customers for payment", + subject: "Invoice {{invoice_number}} - {{company_name}}", + html_body: billing_invoice_html_template(), + text_body: billing_invoice_text_template(), + category: "transactional", + status: "active", + is_system: true, + variables: %{ + "user_email" => "Customer's email address", + "user_name" => "Customer's name", + "invoice_number" => "Invoice number", + "invoice_date" => "Invoice date", + "due_date" => "Payment due date", + "subtotal" => "Subtotal amount", + "tax_amount" => "Tax amount", + "total" => "Total amount", + "currency" => "Currency code", + "line_items_html" => "HTML table of line items", + "line_items_text" => "Text list of line items", + "company_name" => "Company name", + "company_address" => "Company address", + "company_vat" => "Company VAT number", + "bank_name" => "Bank name", + "bank_iban" => "Bank IBAN", + "bank_swift" => "Bank SWIFT/BIC", + "payment_terms" => "Payment terms", + "invoice_url" => "URL to view invoice online" + }, + metadata: %{"source_module" => "billing"} }, - metadata: %{"source_module" => "billing"} - }, - %{ - name: "billing_credit_note", - slug: "billing-credit-note", - display_name: "Billing Credit Note", - description: "Credit note email sent to customers when a refund is issued", - subject: "Credit Note {{credit_note_number}} - Refund Issued - {{company_name}}", - html_body: billing_credit_note_html_template(), - text_body: billing_credit_note_text_template(), - category: "transactional", - status: "active", - is_system: true, - variables: %{ - "user_email" => "Customer's email address", - "user_name" => "Customer's name", - "credit_note_number" => "Credit note number", - "invoice_number" => "Original invoice number", - "refund_date" => "Date of refund", - "refund_amount" => "Refund amount", - "refund_reason" => "Reason for refund", - "transaction_number" => "Transaction reference number", - "currency" => "Currency code", - "company_name" => "Company name", - "company_address" => "Company address", - "company_vat" => "Company VAT number", - "credit_note_url" => "URL to view credit note online" + %{ + name: "billing_receipt", + slug: "billing-receipt", + display_name: "Billing Receipt", + description: "Receipt email sent to customers after payment confirmation", + subject: "Receipt {{receipt_number}} - {{company_name}}", + html_body: billing_receipt_html_template(), + text_body: billing_receipt_text_template(), + category: "transactional", + status: "active", + is_system: true, + variables: %{ + "user_email" => "Customer's email address", + "user_name" => "Customer's name", + "receipt_number" => "Receipt number", + "invoice_number" => "Original invoice number", + "payment_date" => "Date of payment", + "subtotal" => "Subtotal amount", + "tax_amount" => "Tax amount", + "total" => "Total amount", + "paid_amount" => "Amount paid", + "currency" => "Currency code", + "line_items_html" => "HTML table of line items", + "line_items_text" => "Text list of line items", + "company_name" => "Company name", + "company_address" => "Company address", + "company_vat" => "Company VAT number", + "receipt_url" => "URL to view receipt online" + }, + metadata: %{"source_module" => "billing"} }, - metadata: %{"source_module" => "billing"} - }, - %{ - name: "billing_payment_confirmation", - slug: "billing-payment-confirmation", - display_name: "Billing Payment Confirmation", - description: "Payment confirmation email sent to customers when a payment is received", - subject: "Payment Received - {{confirmation_number}} - {{company_name}}", - html_body: billing_payment_confirmation_html_template(), - text_body: billing_payment_confirmation_text_template(), - category: "transactional", - status: "active", - is_system: true, - variables: %{ - "user_email" => "Customer's email address", - "user_name" => "Customer's name", - "confirmation_number" => "Payment confirmation number", - "invoice_number" => "Invoice number", - "payment_date" => "Date of payment", - "payment_amount" => "Payment amount", - "payment_method" => "Payment method", - "transaction_number" => "Transaction reference number", - "invoice_total" => "Invoice total", - "total_paid" => "Total paid so far", - "remaining_balance" => "Remaining balance", - "is_final_payment" => "Whether this is the final payment", - "currency" => "Currency code", - "company_name" => "Company name", - "company_address" => "Company address", - "payment_url" => "URL to view payment confirmation online" + %{ + name: "billing_credit_note", + slug: "billing-credit-note", + display_name: "Billing Credit Note", + description: "Credit note email sent to customers when a refund is issued", + subject: "Credit Note {{credit_note_number}} - Refund Issued - {{company_name}}", + html_body: billing_credit_note_html_template(), + text_body: billing_credit_note_text_template(), + category: "transactional", + status: "active", + is_system: true, + variables: %{ + "user_email" => "Customer's email address", + "user_name" => "Customer's name", + "credit_note_number" => "Credit note number", + "invoice_number" => "Original invoice number", + "refund_date" => "Date of refund", + "refund_amount" => "Refund amount", + "refund_reason" => "Reason for refund", + "transaction_number" => "Transaction reference number", + "currency" => "Currency code", + "company_name" => "Company name", + "company_address" => "Company address", + "company_vat" => "Company VAT number", + "credit_note_url" => "URL to view credit note online" + }, + metadata: %{"source_module" => "billing"} }, - metadata: %{"source_module" => "billing"} - } - ] + %{ + name: "billing_payment_confirmation", + slug: "billing-payment-confirmation", + display_name: "Billing Payment Confirmation", + description: "Payment confirmation email sent to customers when a payment is received", + subject: "Payment Received - {{confirmation_number}} - {{company_name}}", + html_body: billing_payment_confirmation_html_template(), + text_body: billing_payment_confirmation_text_template(), + category: "transactional", + status: "active", + is_system: true, + variables: %{ + "user_email" => "Customer's email address", + "user_name" => "Customer's name", + "confirmation_number" => "Payment confirmation number", + "invoice_number" => "Invoice number", + "payment_date" => "Date of payment", + "payment_amount" => "Payment amount", + "payment_method" => "Payment method", + "transaction_number" => "Transaction reference number", + "invoice_total" => "Invoice total", + "total_paid" => "Total paid so far", + "remaining_balance" => "Remaining balance", + "is_final_payment" => "Whether this is the final payment", + "currency" => "Currency code", + "company_name" => "Company name", + "company_address" => "Company address", + "payment_url" => "URL to view payment confirmation online" + }, + metadata: %{"source_module" => "billing"} + } + ]) results = Enum.map(system_templates, fn template_attrs -> @@ -807,6 +809,21 @@ defmodule PhoenixKit.Modules.Emails.Templates do # Private helper functions + # Wraps string fields in language maps for multilingual schema compatibility + defp wrap_i18n_fields(templates) do + Enum.map(templates, fn t -> + t + |> Map.update(:display_name, nil, &wrap_i18n/1) + |> Map.update(:description, nil, &wrap_i18n/1) + |> Map.update(:subject, nil, &wrap_i18n/1) + |> Map.update(:html_body, nil, &wrap_i18n/1) + |> Map.update(:text_body, nil, &wrap_i18n/1) + end) + end + + defp wrap_i18n(v) when is_binary(v), do: %{"en" => v} + defp wrap_i18n(v), do: v + defp apply_filters(query, opts) do Enum.reduce(opts, query, fn {:category, category}, q when is_binary(category) -> From 5409b6525edc5722367cdaeef64f4e5f9f004c8d Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 17 Mar 2026 17:40:04 +0200 Subject: [PATCH 03/15] Fix whitespace in slug format examples on publishing group pages Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/edit.html.heex | 12 +++--------- lib/modules/publishing/web/new.html.heex | 12 +++--------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/lib/modules/publishing/web/edit.html.heex b/lib/modules/publishing/web/edit.html.heex index b874a4073..77a00a174 100644 --- a/lib/modules/publishing/web/edit.html.heex +++ b/lib/modules/publishing/web/edit.html.heex @@ -50,18 +50,12 @@ )}

- ✓ {gettext("Valid examples")}: blog, - product-updates - , + ✓ {gettext("Valid examples")}: blog, product-updates, news-2025

- ✗ {gettext("Invalid examples")}: Blog, - product_updates - , -news, - - my blog - + ✗ {gettext("Invalid examples")}: Blog, product_updates, -news, + my blog

diff --git a/lib/modules/publishing/web/new.html.heex b/lib/modules/publishing/web/new.html.heex index bbc04ed11..8cde9fb20 100644 --- a/lib/modules/publishing/web/new.html.heex +++ b/lib/modules/publishing/web/new.html.heex @@ -57,18 +57,12 @@ )}

- ✓ {gettext("Valid examples")}: blog, - product-updates - , + ✓ {gettext("Valid examples")}: blog, product-updates, news-2025

- ✗ {gettext("Invalid examples")}: Blog, - product_updates - , -news, - - my blog - + ✗ {gettext("Invalid examples")}: Blog, product_updates, -news, + my blog

<% slug_value = @form[:slug].value || "" %> From 444344bbe7964e2e3ad7530beb999aa521eee977 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 17 Mar 2026 17:44:52 +0200 Subject: [PATCH 04/15] Hide preview button when creating a new post The preview button requires a saved post UUID to build the preview URL. On the new post page the UUID is nil, making the button non-functional. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/editor.html.heex | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/modules/publishing/web/editor.html.heex b/lib/modules/publishing/web/editor.html.heex index 3fbe0eaa4..41da52434 100644 --- a/lib/modules/publishing/web/editor.html.heex +++ b/lib/modules/publishing/web/editor.html.heex @@ -159,14 +159,16 @@ )}
- + <%= unless @is_new_post do %> + + <% end %> <%= if @form["status"] == "published" && @public_url do %> Date: Tue, 17 Mar 2026 18:52:11 +0200 Subject: [PATCH 05/15] Add warning flash kind and use it for new post language switching The flash component only supported :info and :error kinds, but :warning was already used in other parts of the codebase without being rendered. Add :warning with alert-warning styling and triangle icon. Use it for the language switch hint on unsaved new posts instead of :error. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/editor.ex | 2 +- lib/phoenix_kit_web/components/core/flash.ex | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex index 0edfdac8b..34bdaa2e3 100644 --- a/lib/modules/publishing/web/editor.ex +++ b/lib/modules/publishing/web/editor.ex @@ -955,7 +955,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do def handle_event("switch_language", %{"language" => new_language}, socket) do if socket.assigns[:is_new_post] do {:noreply, - put_flash(socket, :error, gettext("Please save the post first before switching languages"))} + put_flash(socket, :warning, gettext("Save the post to enable language switching"))} else do_switch_language(socket, new_language) end diff --git a/lib/phoenix_kit_web/components/core/flash.ex b/lib/phoenix_kit_web/components/core/flash.ex index d4098a02c..2866c4689 100644 --- a/lib/phoenix_kit_web/components/core/flash.ex +++ b/lib/phoenix_kit_web/components/core/flash.ex @@ -21,7 +21,7 @@ defmodule PhoenixKitWeb.Components.Core.Flash do attr :id, :string, doc: "the optional id of flash container" attr :flash, :map, default: %{}, doc: "the map of flash messages to display" attr :title, :string, default: nil - attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" + attr :kind, :atom, values: [:info, :warning, :error], doc: "used for styling and flash lookup" attr :autoclose, :any, default: 5000, doc: "Auto-dismiss delay in ms, or false to disable" attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" @@ -45,9 +45,11 @@ defmodule PhoenixKitWeb.Components.Core.Flash do
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> + <.icon :if={@kind == :warning} name="hero-exclamation-triangle" class="size-5 shrink-0" /> <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />

{@title}

@@ -64,6 +66,7 @@ defmodule PhoenixKitWeb.Components.Core.Flash do class={[ "h-full", @kind == :info && "bg-info-content/30", + @kind == :warning && "bg-warning-content/30", @kind == :error && "bg-error-content/30" ]} style="width: 100%" @@ -88,8 +91,9 @@ defmodule PhoenixKitWeb.Components.Core.Flash do def flash_group(assigns) do ~H"""
- <.flash kind={:info} title="Success!" flash={@flash} /> - <.flash kind={:error} title="Error!" flash={@flash} autoclose={8000} /> + <.flash kind={:info} title={gettext("Success!")} flash={@flash} /> + <.flash kind={:warning} title={gettext("Note")} flash={@flash} /> + <.flash kind={:error} title={gettext("Error!")} flash={@flash} autoclose={8000} />
""" end From ce47e6d7c10126fae2a7e4049bee58b17a8a4140 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 17 Mar 2026 20:07:45 +0200 Subject: [PATCH 06/15] Remove auto-title/slug from content, add title-driven slug generation Replace content-based auto-extraction of title (from H1 heading) and slug with explicit title input and real-time slug generation from title. - Remove maybe_update_title_from_content, preserve_auto_title, detect_title_manual_set, revert_title_to_auto, and all title tracking assigns (title_manually_set, last_auto_title) - Replace maybe_update_slug_from_content with maybe_update_slug_from_title that generates slug directly from the title field - Title and slug are required to save (autosave silently skips if empty) - Slug manual-set detection uses _target to avoid stale browser values - Preserve server-side slug when user is typing in title field - Detect slug constraint errors on post creation and show clear message - Add editor forms unit tests and slug update integration tests - Update template help text and remove phx:update-title JS listener Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/posts.ex | 12 +- lib/modules/publishing/web/editor.ex | 141 +++++----- lib/modules/publishing/web/editor.html.heex | 12 +- lib/modules/publishing/web/editor/forms.ex | 188 ++----------- .../publishing/web/editor/persistence.ex | 64 ++++- test/modules/publishing/editor_forms_test.exs | 250 ++++++++++++++++++ .../integration/slug_update_test.exs | 65 +++++ 7 files changed, 490 insertions(+), 242 deletions(-) create mode 100644 test/modules/publishing/editor_forms_test.exs create mode 100644 test/modules/publishing/integration/slug_update_test.exs diff --git a/lib/modules/publishing/posts.ex b/lib/modules/publishing/posts.ex index b8732cc2d..09f8d059c 100644 --- a/lib/modules/publishing/posts.ex +++ b/lib/modules/publishing/posts.ex @@ -16,7 +16,6 @@ defmodule PhoenixKit.Modules.Publishing.Posts do alias PhoenixKit.Modules.Publishing.DBStorage alias PhoenixKit.Modules.Publishing.LanguageHelpers alias PhoenixKit.Modules.Publishing.ListingCache - alias PhoenixKit.Modules.Publishing.Metadata alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub alias PhoenixKit.Modules.Publishing.Shared alias PhoenixKit.Modules.Publishing.SlugHelpers @@ -650,12 +649,15 @@ defmodule PhoenixKit.Modules.Publishing.Posts do true -> {:error, :slug_already_exists} - {:error, %Ecto.Changeset{errors: errors}} -> - if Keyword.has_key?(errors, :slug), + {:error, %Ecto.Changeset{} = changeset} -> + Logger.warning("[Publishing] slug update changeset error: #{inspect(changeset.errors)}") + + if Keyword.has_key?(changeset.errors, :slug), do: {:error, :slug_already_exists}, else: {:error, :db_update_failed} {:error, reason} -> + Logger.warning("[Publishing] slug update failed: #{inspect(reason)}") {:error, reason} end end @@ -709,12 +711,10 @@ defmodule PhoenixKit.Modules.Publishing.Posts do end end - defp resolve_post_title(params, post, content) do - extracted_title = Metadata.extract_title_from_content(content) + defp resolve_post_title(params, post, _content) do post_metadata = post[:metadata] || %{} Map.get(params, "title") || - if(extracted_title != Constants.default_title(), do: extracted_title) || post_metadata[:title] || Constants.default_title() end diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex index 34bdaa2e3..10072f1c3 100644 --- a/lib/modules/publishing/web/editor.ex +++ b/lib/modules/publishing/web/editor.ex @@ -28,8 +28,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do alias Phoenix.LiveView.JS alias PhoenixKit.Modules.AI alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.Constants - alias PhoenixKit.Modules.Publishing.Metadata alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes @@ -438,21 +436,12 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do is_published = form["status"] == "published" - # Seed auto-title from existing content for manual-set detection - extracted_title = Metadata.extract_title_from_content(post.content || "") - auto_title = if extracted_title == Constants.default_title(), do: "", else: extracted_title - form_title = Map.get(form, "title", "") - title_manually_set = form_title != "" and auto_title != "" and form_title != auto_title - sock = socket |> assign(:group_mode, group_mode) |> assign(:post, %{post | group: group_slug}) |> assign(:group_name, Publishing.group_name(group_slug) || group_slug) - |> Forms.assign_form_with_tracking(form, - last_auto_title: auto_title, - title_manually_set: title_manually_set - ) + |> Forms.assign_form_with_tracking(form) |> assign(:content, post.content) |> assign(:available_languages, post.available_languages) |> assign(:all_enabled_languages, all_enabled_languages) @@ -495,35 +484,49 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do if socket.assigns.readonly? or socket.assigns.translation_locked? do {:noreply, socket} else + target = Map.get(params, "_target", []) params = params |> Map.drop(["_target"]) params = Forms.preserve_auto_url_slug(params, socket) - params = Forms.preserve_auto_title(params, socket) + + # When typing in the title field, the browser sends stale slug/url_slug values. + # Preserve the server's current slug to avoid overwriting the auto-generated value. + params = + if target == ["title"] do + params + |> Map.put("slug", socket.assigns.form["slug"] || "") + |> Map.put("url_slug", socket.assigns.form["url_slug"] || "") + else + params + end new_form = socket.assigns.form |> Map.merge(params) |> Forms.normalize_form() + # Only detect manual slug edits when the user is directly editing the slug field, + # not when slug arrives stale from the browser during title editing 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 + if target == ["slug"], + do: detect_slug_manual_set(params, new_form, socket), + else: socket.assigns.slug_manually_set url_slug_manually_set = - if Map.has_key?(params, "url_slug") do - url_slug_value = Map.get(new_form, "url_slug", "") - url_slug_value != "" && url_slug_value != socket.assigns.last_auto_url_slug - else - socket.assigns.url_slug_manually_set - end - - {new_form, title_manually_set} = - Forms.detect_title_manual_set(params, new_form, socket) + if target == ["url_slug"], + do: detect_url_slug_manual_set(params, new_form, socket), + else: socket.assigns.url_slug_manually_set + + # Generate slug from title in real-time + {socket_with_slug, new_form, slug_events} = + maybe_generate_slug_from_title( + socket, + params, + new_form, + slug_manually_set, + url_slug_manually_set + ) - has_changes = Forms.dirty?(socket.assigns.post, new_form, socket.assigns.content) + has_changes = Forms.dirty?(socket_with_slug.assigns.post, new_form, socket.assigns.content) language = Helpers.editor_language(socket.assigns) new_status = new_form["status"] @@ -553,16 +556,16 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do public_url = Helpers.build_public_url(updated_post, language) socket = - socket + socket_with_slug |> assign(:form, new_form) |> assign(:post, updated_post) |> assign(:slug_manually_set, slug_manually_set) |> assign(:url_slug_manually_set, url_slug_manually_set) - |> assign(:title_manually_set, title_manually_set) |> assign(:has_pending_changes, has_changes) |> assign(:public_url, public_url) |> clear_flash() |> push_event("changes-status", %{has_changes: has_changes}) + |> Forms.push_slug_events(slug_events) socket = if has_changes, do: schedule_autosave(socket), else: socket @@ -580,25 +583,20 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do if socket.assigns.readonly? or socket.assigns.translation_locked? do {:noreply, socket} else - {socket, new_form, slug_events} = Forms.maybe_update_slug_from_content(socket, content) - - # Auto-update title from H1 if not manually set - socket = assign(socket, :form, new_form) - {socket, new_form, title_events} = Forms.maybe_update_title_from_content(socket, content) - - has_changes = Forms.dirty?(socket.assigns.post, new_form, content) + has_changes = Forms.dirty?(socket.assigns.post, socket.assigns.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 = Forms.push_slug_events(socket, slug_events ++ title_events) socket = if has_changes, do: schedule_autosave(socket), else: socket - Collaborative.broadcast_form_change(socket, :content, %{content: content, form: new_form}) + Collaborative.broadcast_form_change(socket, :content, %{ + content: content, + form: socket.assigns.form + }) socket = Collaborative.touch_activity(socket) @@ -606,12 +604,12 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do end end - def handle_event("generate_slug_from_content", _params, socket) do + def handle_event("regenerate_slug", _params, socket) do if socket.assigns.group_mode == "slug" do - content = socket.assigns.content || "" + title = socket.assigns.form["title"] || "" {socket, new_form, slug_events} = - Forms.maybe_update_slug_from_content(socket, content, force: true) + Forms.maybe_update_slug_from_title(socket, title, force: true) has_changes = Forms.dirty?(socket.assigns.post, new_form, socket.assigns.content) @@ -1130,21 +1128,13 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do end def handle_info({:editor_content_changed, %{content: content}}, socket) do - {socket, new_form, slug_events} = Forms.maybe_update_slug_from_content(socket, content) - - # Auto-update title from H1 if not manually set - socket = assign(socket, :form, new_form) - {socket, new_form, title_events} = Forms.maybe_update_title_from_content(socket, content) - - has_changes = Forms.dirty?(socket.assigns.post, new_form, content) + has_changes = Forms.dirty?(socket.assigns.post, socket.assigns.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}) - |> Forms.push_slug_events(slug_events ++ title_events) socket = if has_changes, do: schedule_autosave(socket), else: socket @@ -1492,17 +1482,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do case re_read_post(socket) do {:ok, post} -> form = Forms.post_form(post) - extracted_title = Metadata.extract_title_from_content(post.content || "") - - auto_title = - if extracted_title == Constants.default_title(), do: "", else: extracted_title socket |> assign(:post, %{post | group: socket.assigns.group_slug}) - |> Forms.assign_form_with_tracking(form, - last_auto_title: auto_title, - title_manually_set: false - ) + |> Forms.assign_form_with_tracking(form) |> assign(:content, post.content) |> assign(:has_pending_changes, false) |> push_event("changes-status", %{has_changes: false}) @@ -1516,6 +1499,42 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do end end + defp detect_slug_manual_set(params, form, socket) do + if Map.has_key?(params, "slug") do + slug_value = Map.get(form, "slug", "") + slug_value != "" && slug_value != socket.assigns.last_auto_slug + else + socket.assigns.slug_manually_set + end + end + + defp detect_url_slug_manual_set(params, form, socket) do + if Map.has_key?(params, "url_slug") do + url_slug_value = Map.get(form, "url_slug", "") + url_slug_value != "" && url_slug_value != socket.assigns.last_auto_url_slug + else + socket.assigns.url_slug_manually_set + end + end + + defp maybe_generate_slug_from_title( + socket, + params, + form, + slug_manually_set, + url_slug_manually_set + ) do + if Map.has_key?(params, "title") do + socket + |> assign(:form, form) + |> assign(:slug_manually_set, slug_manually_set) + |> assign(:url_slug_manually_set, url_slug_manually_set) + |> Forms.maybe_update_slug_from_title(form["title"]) + else + {socket, form, []} + end + end + defp maybe_reclaim_lock(socket) do if socket.assigns[:lock_released_by_timeout] do Collaborative.try_reclaim_lock(socket) diff --git a/lib/modules/publishing/web/editor.html.heex b/lib/modules/publishing/web/editor.html.heex index 41da52434..5cbfc1423 100644 --- a/lib/modules/publishing/web/editor.html.heex +++ b/lib/modules/publishing/web/editor.html.heex @@ -77,14 +77,6 @@ } }); - // Listen for title update event from LiveView - window.addEventListener("phx:update-title", function(e) { - const titleInput = document.getElementById("title-input"); - if (titleInput && e.detail.title) { - titleInput.value = e.detail.title; - } - }); - // Listen for url_slug update event from LiveView (for translations) window.addEventListener("phx:update-url-slug", function(e) { const urlSlugInput = document.getElementById("url-slug-input"); @@ -708,7 +700,7 @@ />

<%= if @is_primary_language do %> - {gettext("Auto-populated from the first heading. Edit to override.")} + {gettext("Required. The slug will be generated from the title.")} <% else %> {gettext("Leave empty to use the primary language title.")} <% end %> @@ -812,7 +804,7 @@ value={@form["slug"]} pattern="[a-z0-9]+(-[a-z0-9]+)*" class={"input input-bordered w-full lowercase #{if edit_disabled? or @viewing_older_version, do: "input-disabled bg-base-200"}"} - placeholder={gettext("auto-generated from first heading")} + placeholder={gettext("auto-generated from title")} title={ gettext( "Use lowercase letters, numbers, and hyphens only. No spaces or special characters." diff --git a/lib/modules/publishing/web/editor/forms.ex b/lib/modules/publishing/web/editor/forms.ex index f6a61bc98..d8184ad24 100644 --- a/lib/modules/publishing/web/editor/forms.ex +++ b/lib/modules/publishing/web/editor/forms.ex @@ -8,7 +8,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.Constants - alias PhoenixKit.Modules.Publishing.Metadata alias PhoenixKit.Utils.Date, as: UtilsDate alias PhoenixKit.Utils.Slug @@ -184,7 +183,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do def assign_form_with_tracking(socket, form, opts \\ []) do {slug_manually_set, last_auto_slug} = resolve_slug_tracking(socket, form, opts) {url_slug_manually_set, last_auto_url_slug} = resolve_url_slug_tracking(socket, form, opts) - {title_manually_set, last_auto_title} = resolve_title_tracking(socket, form, opts) socket |> Phoenix.Component.assign(:form, form) @@ -192,8 +190,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do |> Phoenix.Component.assign(:slug_manually_set, slug_manually_set) |> Phoenix.Component.assign(:last_auto_url_slug, last_auto_url_slug) |> Phoenix.Component.assign(:url_slug_manually_set, url_slug_manually_set) - |> Phoenix.Component.assign(:last_auto_title, last_auto_title) - |> Phoenix.Component.assign(:title_manually_set, title_manually_set) end defp resolve_slug_tracking(socket, form, opts) do @@ -237,110 +233,67 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do {manually_set, last_auto} end - defp resolve_title_tracking(socket, form, opts) do - manually_set = - case Keyword.fetch(opts, :title_manually_set) do - {:ok, value} -> - value - - :error -> - existing = Map.get(socket.assigns, :title_manually_set, false) - title = Map.get(form, "title", "") - last_auto = Map.get(socket.assigns, :last_auto_title, "") - existing || (title != "" and last_auto != "" and title != last_auto) - end - - last_auto = - case Keyword.fetch(opts, :last_auto_title) do - {:ok, value} -> value - :error -> Map.get(socket.assigns, :last_auto_title, "") - end - - {manually_set, last_auto} - end - # ============================================================================ - # Slug Auto-Generation + # Slug Generation from Title # ============================================================================ @doc """ - Updates slug from content if applicable. + Updates slug from title if applicable. Returns {socket, new_form, slug_events}. """ - def maybe_update_slug_from_content(socket, content, opts \\ []) do - content = content || "" + def maybe_update_slug_from_title(socket, title, opts \\ []) do + title = title || "" cond do - not slug_update_applicable?(socket, content) -> + socket.assigns.group_mode != "slug" or String.trim(title) == "" -> no_slug_update(socket) Map.get(socket.assigns, :is_primary_language, true) -> - maybe_update_primary_slug(socket, content, opts) + maybe_update_primary_slug_from_title(socket, title, opts) true -> - maybe_update_translation_url_slug(socket, content, opts) + maybe_update_translation_url_slug_from_title(socket, title, opts) end end - defp slug_update_applicable?(socket, content) do - socket.assigns.group_mode == "slug" and String.trim(content) != "" - end - - defp maybe_update_primary_slug(socket, content, opts) do + defp maybe_update_primary_slug_from_title(socket, title, opts) do force? = Keyword.get(opts, :force, false) slug_manually_set? = Map.get(socket.assigns, :slug_manually_set, false) if not force? and slug_manually_set? do no_slug_update(socket) else - update_slug_from_content(socket, content) + current_slug = socket.assigns.post.slug || Map.get(socket.assigns.form, "slug", "") + + case Publishing.generate_unique_slug(socket.assigns.group_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 end - defp maybe_update_translation_url_slug(socket, content, opts) do + defp maybe_update_translation_url_slug_from_title(socket, title, opts) do force? = Keyword.get(opts, :force, false) url_slug_manually_set? = Map.get(socket.assigns, :url_slug_manually_set, false) if not force? and url_slug_manually_set? do no_slug_update(socket) else - update_url_slug_from_content(socket, content) - end - end + new_url_slug = Slug.slugify(title) + current_url_slug = Map.get(socket.assigns.form, "url_slug", "") - 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 Publishing.generate_unique_slug(socket.assigns.group_slug, title, nil, - current_slug: current_slug - ) do - {:ok, ""} -> - no_slug_update(socket) - - {:ok, new_slug} -> - apply_new_slug(socket, new_slug) - - {:error, _reason} -> + if new_url_slug == "" do no_slug_update(socket) + else + apply_new_url_slug(socket, new_url_slug, current_url_slug) + end end end - defp update_url_slug_from_content(socket, content) do - title = Metadata.extract_title_from_content(content) - current_url_slug = Map.get(socket.assigns.form, "url_slug", "") - - # Generate a slug from the title - new_url_slug = Slug.slugify(title) - - if new_url_slug == "" do - no_slug_update(socket) - else - apply_new_url_slug(socket, new_url_slug, current_url_slug) - end - end + defp no_slug_update(socket), do: {socket, socket.assigns.form, []} defp apply_new_slug(socket, new_slug) do current_slug = Map.get(socket.assigns.form, "slug", "") @@ -390,97 +343,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do end end - # ============================================================================ - # Title Auto-Generation - # ============================================================================ - - @doc """ - Updates the title from content if not manually set. - Returns {socket, form, events}. - """ - def maybe_update_title_from_content(socket, content) do - content = content || "" - title_manually_set? = Map.get(socket.assigns, :title_manually_set, false) - - if title_manually_set? do - {socket, socket.assigns.form, []} - else - extracted = Metadata.extract_title_from_content(content) - new_title = if extracted == Constants.default_title(), do: "", else: extracted - current_title = Map.get(socket.assigns.form, "title", "") - - if new_title != "" and new_title != current_title do - form = Map.put(socket.assigns.form, "title", new_title) - - socket = - socket - |> Phoenix.Component.assign(:last_auto_title, new_title) - |> Phoenix.Component.assign(:title_manually_set, false) - - {socket, form, [{"update-title", %{title: new_title}}]} - else - socket = - if new_title != "" do - Phoenix.Component.assign(socket, :last_auto_title, new_title) - else - socket - end - - {socket, socket.assigns.form, []} - end - end - end - - @doc """ - Preserve auto-generated title when browser sends empty value. - """ - def preserve_auto_title(params, socket) do - browser_title = Map.get(params, "title", "") - last_auto = Map.get(socket.assigns, :last_auto_title, "") - manually_set = Map.get(socket.assigns, :title_manually_set, false) - - if browser_title == "" and last_auto != "" and not manually_set do - Map.put(params, "title", last_auto) - else - params - end - end - - @doc """ - Detects whether the user manually set the title field. - Returns {form, title_manually_set}. - """ - def detect_title_manual_set(params, form, socket) do - if Map.has_key?(params, "title") do - title_value = Map.get(form, "title", "") - - if title_value != "" do - manually_set = title_value != Map.get(socket.assigns, :last_auto_title, "") - {form, manually_set} - else - # User cleared title — revert to auto from H1 - revert_title_to_auto(form, socket) - end - else - {form, Map.get(socket.assigns, :title_manually_set, false)} - end - end - - @doc """ - Reverts the title to the auto-extracted H1 heading. - Returns {form, false}. - """ - def revert_title_to_auto(form, socket) do - extracted = Metadata.extract_title_from_content(socket.assigns.content || "") - auto_title = if extracted == Constants.default_title(), do: "", else: extracted - - if auto_title != "" do - {Map.put(form, "title", auto_title), false} - else - {form, false} - end - end - @doc """ Preserve auto-generated url_slug when browser sends empty value. """ diff --git a/lib/modules/publishing/web/editor/persistence.ex b/lib/modules/publishing/web/editor/persistence.ex index 023446f6c..d573b0f8f 100644 --- a/lib/modules/publishing/web/editor/persistence.ex +++ b/lib/modules/publishing/web/editor/persistence.ex @@ -26,6 +26,39 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do Returns {:noreply, socket}. """ def perform_save(socket) do + is_autosaving = Map.get(socket.assigns, :is_autosaving, false) + title = (socket.assigns.form["title"] || "") |> String.trim() + slug = (socket.assigns.form["slug"] || "") |> String.trim() + + cond do + title == "" -> + if is_autosaving do + {:noreply, socket} + else + {:noreply, + Phoenix.LiveView.put_flash(socket, :warning, gettext("Title is required to save."))} + end + + socket.assigns.group_mode == "slug" and slug == "" -> + if is_autosaving do + {:noreply, socket} + else + {:noreply, + Phoenix.LiveView.put_flash( + socket, + :warning, + gettext( + "Slug is required. Enter a title to auto-generate one, or type a slug manually." + ) + )} + end + + true -> + do_perform_save_with_params(socket) + end + end + + defp do_perform_save_with_params(socket) do params = socket.assigns.form |> Map.take(["status", "published_at", "slug", "featured_image_uuid", "url_slug", "title"]) @@ -648,7 +681,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do Phoenix.LiveView.put_flash( socket, :error, - gettext("Title is required. Add a heading to your content or enter a title manually.") + gettext("Title is required.") )} end @@ -658,6 +691,17 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do {:noreply, Phoenix.LiveView.put_flash(socket, :error, gettext("Failed to save post"))} end + defp slug_constraint_error?(changeset) do + Keyword.has_key?(changeset.errors, :slug) or + Enum.any?(changeset.errors, fn + {:group_uuid, {_, opts}} -> + Keyword.get(opts, :constraint_name) == "idx_publishing_posts_group_slug" + + _ -> + false + end) + end + defp handle_post_creation_error(socket, :invalid_slug, _fallback_message) do {:noreply, Phoenix.LiveView.put_flash( @@ -674,10 +718,26 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do Phoenix.LiveView.put_flash( socket, :error, - gettext("A post with that slug already exists") + gettext( + "A post with that slug already exists. Please choose a different title or edit the slug manually." + ) )} end + defp handle_post_creation_error(socket, %Ecto.Changeset{} = changeset, fallback_message) do + if slug_constraint_error?(changeset) do + handle_post_creation_error(socket, :slug_already_exists, fallback_message) + else + group = socket.assigns[:group_slug] + + Logger.warning( + "[Publishing.Editor] Post creation failed in #{group}: #{inspect(changeset.errors)}" + ) + + {:noreply, Phoenix.LiveView.put_flash(socket, :error, fallback_message)} + end + end + defp handle_post_creation_error(socket, reason, fallback_message) do group = socket.assigns[:group_slug] Logger.warning("[Publishing.Editor] Post creation failed in #{group}: #{inspect(reason)}") diff --git a/test/modules/publishing/editor_forms_test.exs b/test/modules/publishing/editor_forms_test.exs new file mode 100644 index 000000000..443c66dc8 --- /dev/null +++ b/test/modules/publishing/editor_forms_test.exs @@ -0,0 +1,250 @@ +defmodule PhoenixKit.Modules.Publishing.Web.Editor.FormsTest do + use ExUnit.Case, async: true + + alias PhoenixKit.Modules.Publishing.Web.Editor.Forms + + # ============================================================================ + # Helper to build a minimal socket with assigns for testing + # ============================================================================ + + defp build_socket(overrides) do + defaults = %{ + form: %{"title" => "", "slug" => "", "url_slug" => "", "status" => "draft"}, + post: %{slug: "", uuid: nil, metadata: %{status: "draft"}}, + group_slug: "blog", + group_mode: "slug", + is_primary_language: true, + slug_manually_set: false, + last_auto_slug: "", + url_slug_manually_set: false, + last_auto_url_slug: "" + } + + merged = Map.merge(defaults, overrides) + # Override form fully if provided + merged = + if overrides[:form], + do: %{merged | form: Map.merge(defaults.form, overrides.form)}, + else: merged + + # Build a proper Phoenix.LiveView.Socket with assigns + %Phoenix.LiveView.Socket{} + |> Phoenix.Component.assign(merged) + end + + # ============================================================================ + # maybe_update_slug_from_title/3 + # ============================================================================ + + describe "maybe_update_slug_from_title/3" do + test "generates slug from title for primary language" do + socket = build_socket(%{group_mode: "slug", is_primary_language: true}) + {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "Hello World") + + assert form["slug"] == "hello-world" + assert [{"update-slug", %{slug: "hello-world"}}] = events + end + + test "returns no update for empty title" do + socket = build_socket(%{group_mode: "slug"}) + {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "") + + assert form["slug"] == "" + assert events == [] + end + + test "returns no update for nil title" do + socket = build_socket(%{group_mode: "slug"}) + {_socket, _form, events} = Forms.maybe_update_slug_from_title(socket, nil) + + assert events == [] + end + + test "returns no update for timestamp mode" do + socket = build_socket(%{group_mode: "timestamp"}) + {_socket, _form, events} = Forms.maybe_update_slug_from_title(socket, "Hello World") + + assert events == [] + end + + test "respects slug_manually_set flag" do + socket = build_socket(%{slug_manually_set: true, form: %{"slug" => "custom-slug"}}) + {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "Different Title") + + assert form["slug"] == "custom-slug" + assert events == [] + end + + test "overrides slug when force option is set" do + socket = build_socket(%{slug_manually_set: true, form: %{"slug" => "custom-slug"}}) + + {_socket, form, events} = + Forms.maybe_update_slug_from_title(socket, "New Title", force: true) + + assert form["slug"] == "new-title" + assert [{"update-slug", _}] = events + end + + test "generates url_slug for translation language" do + socket = build_socket(%{is_primary_language: false, form: %{"url_slug" => ""}}) + {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "Translated Title") + + assert form["url_slug"] == "translated-title" + assert [{"update-url-slug", %{url_slug: "translated-title"}}] = events + end + + test "respects url_slug_manually_set for translations" do + socket = + build_socket(%{ + is_primary_language: false, + url_slug_manually_set: true, + form: %{"url_slug" => "custom-url"} + }) + + {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "Other Title") + + assert form["url_slug"] == "custom-url" + assert events == [] + end + + test "no update when slug already matches" do + socket = build_socket(%{form: %{"slug" => "hello-world"}}) + {_socket, _form, events} = Forms.maybe_update_slug_from_title(socket, "Hello World") + + assert events == [] + end + end + + # ============================================================================ + # assign_form_with_tracking/3 + # ============================================================================ + + describe "assign_form_with_tracking/3" do + test "assigns slug tracking state" do + socket = build_socket(%{}) + form = %{"title" => "Test", "slug" => "test", "status" => "draft"} + + result = Forms.assign_form_with_tracking(socket, form) + + assert result.assigns.form == form + assert result.assigns.slug_manually_set == false + assert result.assigns.last_auto_slug == "test" + end + + test "does not assign title_manually_set (removed)" do + socket = build_socket(%{}) + form = %{"title" => "Test", "slug" => "test", "status" => "draft"} + + result = Forms.assign_form_with_tracking(socket, form) + + refute Map.has_key?(result.assigns, :title_manually_set) + refute Map.has_key?(result.assigns, :last_auto_title) + end + end + + # ============================================================================ + # Form Building + # ============================================================================ + + describe "post_form/1" do + test "builds form with title from metadata" do + post = %{ + metadata: %{ + title: "My Post", + status: "draft", + published_at: nil, + featured_image_uuid: nil, + url_slug: nil + }, + slug: "my-post", + mode: :slug, + content: "# My Post\nContent here", + url_slug: nil + } + + form = Forms.post_form(post) + + assert form["title"] == "My Post" + assert form["slug"] == "my-post" + assert form["status"] == "draft" + end + + test "returns empty title for Untitled posts" do + post = %{ + metadata: %{ + title: "Untitled", + status: "draft", + published_at: nil, + featured_image_uuid: nil, + url_slug: nil + }, + slug: nil, + mode: "timestamp", + content: "", + url_slug: nil + } + + form = Forms.post_form(post) + assert form["title"] == "" + end + end + + # ============================================================================ + # dirty?/3 + # ============================================================================ + + describe "dirty?/3" do + test "detects title change as dirty" do + post = %{ + metadata: %{ + title: "Original", + status: "draft", + published_at: nil, + featured_image_uuid: nil, + url_slug: nil + }, + slug: "original", + mode: "slug", + content: "content", + url_slug: nil + } + + form = Forms.post_form(post) + modified_form = Map.put(form, "title", "Changed Title") + + assert Forms.dirty?(post, modified_form, "content") + end + + test "detects content change as dirty" do + post = %{ + metadata: %{ + title: "Title", + status: "draft", + published_at: nil, + featured_image_uuid: nil, + url_slug: nil + }, + slug: "title", + mode: "slug", + content: "original content", + url_slug: nil + } + + form = Forms.post_form(post) + assert Forms.dirty?(post, form, "new content") + end + end + + # ============================================================================ + # push_slug_events/2 + # ============================================================================ + + describe "push_slug_events/2" do + test "pushes no events for empty list" do + socket = build_socket(%{}) + result = Forms.push_slug_events(socket, []) + # No crash = success; events are pushed via Phoenix.LiveView.push_event + assert result + end + end +end diff --git a/test/modules/publishing/integration/slug_update_test.exs b/test/modules/publishing/integration/slug_update_test.exs new file mode 100644 index 000000000..e701f4891 --- /dev/null +++ b/test/modules/publishing/integration/slug_update_test.exs @@ -0,0 +1,65 @@ +defmodule PhoenixKit.Integration.Publishing.SlugUpdateTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Modules.Publishing + alias PhoenixKit.Modules.Publishing.Groups + alias PhoenixKit.Modules.Publishing.Posts + + describe "slug update on existing post" do + setup do + {:ok, group} = Groups.add_group("Slug Test", mode: "slug", slug: "slug-test") + %{group: group} + end + + test "can create post with explicit slug and update it", %{group: group} do + # Create post with title and slug + {:ok, post} = + Posts.create_post(group["slug"], %{title: "Original Title", slug: "original-title"}) + + assert post.slug == "original-title" + + # Update to a different slug + result = + Publishing.update_post(group["slug"], post, %{ + "slug" => "new-slug", + "title" => "Original Title", + "content" => "Some content", + "status" => "draft" + }) + + assert {:ok, updated} = result + assert updated.slug == "new-slug" + end + + test "can create post with auto-generated slug from title", %{group: group} do + {:ok, post} = + Posts.create_post(group["slug"], %{title: "My Great Post"}) + + assert post.slug == "my-great-post" + end + + test "can create post with empty title (gets untitled slug)", %{group: group} do + {:ok, post} = + Posts.create_post(group["slug"], %{title: ""}) + + assert post.slug == "untitled" + end + + test "can update slug from untitled to real slug", %{group: group} do + # This simulates the bug: post created with "untitled" slug, user types title, slug changes + {:ok, post} = Posts.create_post(group["slug"], %{title: ""}) + assert post.slug == "untitled" + + result = + Publishing.update_post(group["slug"], post, %{ + "slug" => "hello", + "title" => "Hello", + "content" => "content", + "status" => "draft" + }) + + assert {:ok, updated} = result + assert updated.slug == "hello" + end + end +end From 665ea5ecb6df70e53ae0a38c221e73f4198608ec Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 17 Mar 2026 20:14:41 +0200 Subject: [PATCH 07/15] Fix translation reload showing primary language content reload_translated_content and reload_post called re_read_post without passing the current language, causing read_post_by_uuid to fall back to the primary language. After a successful translation, the editor would show English content instead of the translated content until refresh. Pass current_language to re_read_post in all places that reload content. Add integration test verifying language-specific content retrieval. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/editor.ex | 6 +- .../publishing/web/editor/persistence.ex | 6 +- .../integration/translation_reload_test.exs | 70 +++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 test/modules/publishing/integration/translation_reload_test.exs diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex index 10072f1c3..b958eae35 100644 --- a/lib/modules/publishing/web/editor.ex +++ b/lib/modules/publishing/web/editor.ex @@ -1479,7 +1479,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do end defp reload_post_on_lock_acquired(socket) do - case re_read_post(socket) do + case re_read_post(socket, socket.assigns[:current_language]) do {:ok, post} -> form = Forms.post_form(post) @@ -1553,11 +1553,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do assign(socket, :autosave_timer, timer_ref) end - defp re_read_post(socket) do + defp re_read_post(socket, language \\ nil) do case socket.assigns[:post] do nil -> {:error, :no_post} %{uuid: nil} -> {:error, :no_uuid} - post -> Publishing.read_post_by_uuid(post.uuid) + post -> Publishing.read_post_by_uuid(post.uuid, language) end end diff --git a/lib/modules/publishing/web/editor/persistence.ex b/lib/modules/publishing/web/editor/persistence.ex index d573b0f8f..b66272451 100644 --- a/lib/modules/publishing/web/editor/persistence.ex +++ b/lib/modules/publishing/web/editor/persistence.ex @@ -770,8 +770,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do """ def reload_translated_content(socket, flash_msg, flash_level) do group_slug = socket.assigns.group_slug + current_language = socket.assigns[:current_language] - case re_read_post(socket) do + case re_read_post(socket, current_language) do {:ok, updated_post} -> current_version = socket.assigns[:current_version] form = Forms.post_form_with_primary_status(group_slug, updated_post, current_version) @@ -816,9 +817,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do """ def reload_post(socket) do group_slug = socket.assigns.group_slug + current_language = socket.assigns[:current_language] current_version = socket.assigns[:current_version] - case re_read_post(socket) do + case re_read_post(socket, current_language) do {:ok, updated_post} -> form = Forms.post_form_with_primary_status(group_slug, updated_post, current_version) diff --git a/test/modules/publishing/integration/translation_reload_test.exs b/test/modules/publishing/integration/translation_reload_test.exs new file mode 100644 index 000000000..eaca72348 --- /dev/null +++ b/test/modules/publishing/integration/translation_reload_test.exs @@ -0,0 +1,70 @@ +defmodule PhoenixKit.Integration.Publishing.TranslationReloadTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Modules.Publishing + alias PhoenixKit.Modules.Publishing.Groups + alias PhoenixKit.Modules.Publishing.Posts + + describe "read_post_by_uuid with language parameter" do + setup do + {:ok, group} = Groups.add_group("Translation Test", mode: "slug", slug: "translation-test") + {:ok, post} = Posts.create_post(group["slug"], %{title: "English Title", slug: "test-post"}) + + # Save the primary language content + {:ok, saved_post} = + Publishing.update_post(group["slug"], post, %{ + "title" => "English Title", + "content" => "English content", + "status" => "draft" + }) + + # Add a translation language + {:ok, _} = + Publishing.add_language_to_post(group["slug"], saved_post[:uuid], "uk", 1) + + # Save translated content + {:ok, translated_post} = + Publishing.read_post_by_uuid(saved_post[:uuid], "uk", 1) + + {:ok, _} = + Publishing.update_post(group["slug"], translated_post, %{ + "title" => "Ukrainian Title", + "content" => "Ukrainian content", + "status" => "draft" + }) + + %{group: group, post_uuid: saved_post[:uuid]} + end + + test "reading without language returns primary language content", %{post_uuid: uuid} do + {:ok, post} = Publishing.read_post_by_uuid(uuid) + assert post.metadata.title == "English Title" + assert post.content == "English content" + end + + test "reading with specific language returns that language's content", %{post_uuid: uuid} do + {:ok, post} = Publishing.read_post_by_uuid(uuid, "uk") + assert post.metadata.title == "Ukrainian Title" + assert post.content == "Ukrainian content" + end + + test "reload_translated_content should read correct language", %{post_uuid: uuid} do + # This test verifies the pattern used in reload_translated_content: + # re_read_post(socket, current_language) should return the translated content, + # NOT the primary language content. + {:ok, primary} = Publishing.read_post_by_uuid(uuid, nil) + {:ok, translated} = Publishing.read_post_by_uuid(uuid, "uk") + + # Primary should return English + assert primary.language == "en-US" + assert primary.metadata.title == "English Title" + + # Translated should return Ukrainian + assert translated.language == "uk" + assert translated.metadata.title == "Ukrainian Title" + + # They should be different + refute primary.content == translated.content + end + end +end From 87c058419a39eaefa6aa2eeb9318914ff417bd36 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 17 Mar 2026 20:29:06 +0200 Subject: [PATCH 08/15] Show full public interface in preview with working language switcher Replace the bare markdown preview with the full public post layout including breadcrumbs, publication date, language switcher, version dropdown, title, and styled content. Language links navigate within the preview instead of going to the public site. Breadcrumbs are non-interactive. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/preview.ex | 67 ++++++++- lib/modules/publishing/web/preview.html.heex | 141 +++++++++++++++++-- 2 files changed, 192 insertions(+), 16 deletions(-) diff --git a/lib/modules/publishing/web/preview.ex b/lib/modules/publishing/web/preview.ex index 1b7d0a181..599abb209 100644 --- a/lib/modules/publishing/web/preview.ex +++ b/lib/modules/publishing/web/preview.ex @@ -1,6 +1,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do @moduledoc """ Preview rendering for publishing posts. + + Shows the full public-facing interface with a preview banner, + allowing editors to see exactly what visitors will see. """ use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitWeb.Gettext @@ -8,12 +11,17 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do require Logger alias PhoenixKit.Modules.Publishing - # alias PhoenixKit.Modules.Publishing.PageBuilder # COMMENTED OUT: Component system + alias PhoenixKit.Modules.Publishing.Constants alias PhoenixKit.Modules.Publishing.Renderer + alias PhoenixKit.Modules.Publishing.Web.Controller.PostRendering + alias PhoenixKit.Modules.Publishing.Web.Controller.Translations alias PhoenixKit.Modules.Publishing.Web.Editor.Helpers + alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes + import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher + @impl true def mount(params, _session, socket) do group_slug = params["group"] || params["category"] || params["type"] @@ -21,14 +29,18 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do socket = socket |> assign(:project_title, Settings.get_project_title()) - |> assign(:page_title, "Preview") + |> assign(:page_title, gettext("Preview")) |> assign(:group_slug, group_slug) |> assign(:group_name, Publishing.group_name(group_slug) || group_slug) |> assign( :current_path, Routes.path("/admin/publishing/#{group_slug}/preview") ) - |> assign(:rendered_content, nil) + |> assign(:post, nil) + |> assign(:html_content, nil) + |> assign(:translations, []) + |> assign(:breadcrumbs, []) + |> assign(:version_dropdown, nil) |> assign(:error, nil) {:ok, socket} @@ -44,12 +56,30 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do {:ok, post} -> case render_markdown_content(post.content) do {:ok, rendered_html} -> + post = Map.put(post, :uuid, post_uuid) + + # Build the same data as the public controller + canonical_language = post.language + + translations = + Translations.build_translation_links(group_slug, post, canonical_language) + + breadcrumbs = PostRendering.build_breadcrumbs(group_slug, post, canonical_language) + + version_dropdown = + PostRendering.build_version_dropdown(group_slug, post, canonical_language) + {:noreply, socket - |> assign(:post, Map.put(post, :uuid, post_uuid)) + |> assign(:post, post) |> assign(:group_slug, post.group) |> assign(:group_name, Publishing.group_name(post.group) || post.group) - |> assign(:rendered_content, rendered_html) + |> assign(:html_content, rendered_html) + |> assign(:current_language, canonical_language) + |> assign(:translations, translations) + |> assign(:breadcrumbs, breadcrumbs) + |> assign(:version_dropdown, version_dropdown) + |> assign(:page_title, post.metadata.title || Constants.default_title()) |> assign(:error, nil)} {:error, error_message} -> @@ -58,7 +88,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do |> assign(:post, Map.put(post, :uuid, post_uuid)) |> assign(:group_slug, post.group) |> assign(:group_name, Publishing.group_name(post.group) || post.group) - |> assign(:rendered_content, nil) + |> assign(:html_content, nil) |> assign(:error, error_message)} end @@ -97,6 +127,31 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do {:noreply, push_navigate(socket, to: destination)} end + # Delegate to PublishingHTML helpers used in the template + defdelegate has_publication_date?(post), to: PublishingHTML + defdelegate format_post_date(post, group_slug), to: PublishingHTML + + @doc false + def build_preview_translations(translations, post, group_slug) do + post_uuid = post[:uuid] + version = post[:version] + + Enum.map(translations, fn translation -> + code = translation[:code] || translation.code + query = URI.encode_query(%{"lang" => code, "v" => version}) + + %{ + code: code, + display_code: translation[:display_code] || code, + name: translation[:name] || translation.name, + flag: translation[:flag] || "", + url: Routes.path("/admin/publishing/#{group_slug}/#{post_uuid}/preview?#{query}"), + status: "published", + exists: true + } + end) + end + defp render_markdown_content(content) when is_binary(content) do content |> Renderer.render_markdown() diff --git a/lib/modules/publishing/web/preview.html.heex b/lib/modules/publishing/web/preview.html.heex index 396f13072..da31c53c2 100644 --- a/lib/modules/publishing/web/preview.html.heex +++ b/lib/modules/publishing/web/preview.html.heex @@ -7,14 +7,14 @@ current_locale={@current_locale} >

- <%!-- Preview Header --%> -
+ <%!-- Preview Banner --%> +
<.icon name="hero-eye" class="w-5 h-5" /> - {gettext("Preview Mode")} - - — {gettext("Showing saved content")} + {gettext("Preview Mode")} + + — {gettext("This is how the post will appear to visitors")}
@@ -26,7 +26,7 @@
- <%!-- Preview Content --%> + <%!-- Content --%> <%= if @error do %>
@@ -38,10 +38,131 @@
<% else %> - <%= if @rendered_content do %> -
- <.markdown_content content={@rendered_content} /> -
+ <%= if @post && @html_content do %> + <%!-- Public post interface --%> +
+ <%!-- Breadcrumb Navigation (non-interactive in preview) --%> + + + <%!-- Post Header --%> +
+ <%= if has_publication_date?(@post) do %> +
+ + + + +
+ <% end %> +
+ <%!-- Language Switcher (links to preview URLs) --%> + <%= if length(@translations) > 1 do %> + <.publishing_language_switcher + languages={build_preview_translations(@translations, @post, @group_slug)} + current_language={@current_language} + show_status={false} + size={:sm} + /> + <% end %> + <%!-- Version History Dropdown --%> + <%= if @version_dropdown do %> + + <% end %> +
+

+ {@post.metadata.title || PhoenixKit.Modules.Publishing.Constants.default_title()} +

+
+ + <%!-- Post Content --%> +
+ + + {raw(@html_content)} +
+ + <%!-- Post Footer --%> +
+ + <.icon name="hero-arrow-left" class="w-4 h-4 mr-2" /> {gettext("Back to %{group}", + group: String.capitalize(@group_slug) + )} + +
+
<% else %>
From 11365df47c462c557d0599de0b6e4a03c8ae3159 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 17 Mar 2026 20:32:32 +0200 Subject: [PATCH 09/15] Move save indicator above title, remove Content heading and help text Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/editor.html.heex | 137 +++++++++----------- 1 file changed, 62 insertions(+), 75 deletions(-) diff --git a/lib/modules/publishing/web/editor.html.heex b/lib/modules/publishing/web/editor.html.heex index 5cbfc1423..d7006261c 100644 --- a/lib/modules/publishing/web/editor.html.heex +++ b/lib/modules/publishing/web/editor.html.heex @@ -686,86 +686,73 @@
- <%!-- Title field --%> -
- -

- <%= if @is_primary_language do %> - {gettext("Required. The slug will be generated from the title.")} - <% else %> - {gettext("Leave empty to use the primary language title.")} - <% end %> -

-
- <%!-- Content header with save status --%> -
-

{gettext("Content")}

-
- <%!-- Other viewers indicator --%> - <%= if @other_viewers != [] do %> -
- - <.icon name="hero-eye" class="w-3 h-3" /> - {ngettext( - "1 other viewing", - "%{count} others viewing", - length(@other_viewers), - count: length(@other_viewers) - )} - -
- <% end %> - <%= cond do %> - <% @is_autosaving -> %> - - - {gettext("Saving...")} - - <% @has_pending_changes -> %> - - {gettext("Unsaved changes")} - - <% @is_new_post -> %> - {gettext("New")} - <% true -> %> - - <.icon name="hero-check" class="w-3 h-3" /> - {gettext("Saved")} - - <% end %> - <% save_disabled = edit_disabled? || @is_autosaving %> - -
+ <% @is_new_post -> %> + {gettext("New")} + <% true -> %> + + <.icon name="hero-check" class="w-3 h-3" /> + {gettext("Saved")} + + <% end %> + <% save_disabled = edit_disabled? || @is_autosaving %> +
- + <%!-- Title field --%> + <%!-- Markdown Editor Component --%> <.live_component module={PhoenixKitWeb.Components.Core.MarkdownEditor} From 35df5a7e217509950cf0e51a1eb43791d9223e3f Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 17 Mar 2026 20:46:02 +0200 Subject: [PATCH 10/15] Use live navigation on public publishing pages Replace links with <.link navigate> across all public templates for smooth client-side transitions instead of full page refreshes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/components/language_switcher.ex | 6 +++--- .../web/templates/all_groups.html.heex | 12 +++++------ .../publishing/web/templates/index.html.heex | 20 +++++++++---------- .../publishing/web/templates/show.html.heex | 15 ++++++++------ 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/lib/modules/publishing/web/components/language_switcher.ex b/lib/modules/publishing/web/components/language_switcher.ex index a682f0fff..de4d08188 100644 --- a/lib/modules/publishing/web/components/language_switcher.ex +++ b/lib/modules/publishing/web/components/language_switcher.ex @@ -176,8 +176,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher do <% else %> <%= if @lang[:url] do %> - @@ -194,7 +194,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher do known={@known} is_primary={@is_primary} /> - + <% else %>
diff --git a/lib/modules/publishing/web/templates/index.html.heex b/lib/modules/publishing/web/templates/index.html.heex index ff6582983..fa8a16962 100644 --- a/lib/modules/publishing/web/templates/index.html.heex +++ b/lib/modules/publishing/web/templates/index.html.heex @@ -10,7 +10,7 @@ <%= for breadcrumb <- @breadcrumbs do %>
  • <%= if breadcrumb.url do %> - {breadcrumb.label} + <.link navigate={breadcrumb.url}>{breadcrumb.label} <% else %> {breadcrumb.label} <% end %> @@ -54,12 +54,12 @@ <% end %>

    - {post.metadata.title} - +

    <% excerpt = @@ -86,12 +86,12 @@ <% end %> - {gettext("Read More →")} - +
  • @@ -104,12 +104,12 @@ <%= if page_num == @page do %> <% else %> - {page_num} - + <% end %> <% end %>
    diff --git a/lib/modules/publishing/web/templates/show.html.heex b/lib/modules/publishing/web/templates/show.html.heex index 604c7bfea..4d91536e5 100644 --- a/lib/modules/publishing/web/templates/show.html.heex +++ b/lib/modules/publishing/web/templates/show.html.heex @@ -12,7 +12,7 @@ <%= for breadcrumb <- @breadcrumbs do %>
  • <%= if breadcrumb.url do %> - {breadcrumb.label} + <.link navigate={breadcrumb.url}>{breadcrumb.label} <% else %> {breadcrumb.label} <% end %> @@ -77,15 +77,15 @@ > <%= for v <- @version_dropdown.versions do %>
  • - v{v.version} <%= if v.is_live do %> live <% end %> - +
  • <% end %> @@ -237,11 +237,14 @@
    <%!-- Post Footer --%> From ceaa22e10e0e1accee4bd84ebf6705f9cddf1c1d Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 17 Mar 2026 20:49:14 +0200 Subject: [PATCH 11/15] Show indeterminate state for translation progress until data arrives Display "Starting..." with an animated progress bar instead of "0/0" when translation is enqueued but progress info hasn't been received yet. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/editor.html.heex | 30 +++++++++++++-------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/modules/publishing/web/editor.html.heex b/lib/modules/publishing/web/editor.html.heex index d7006261c..fa8c51bd5 100644 --- a/lib/modules/publishing/web/editor.html.heex +++ b/lib/modules/publishing/web/editor.html.heex @@ -537,19 +537,27 @@ <% end %> - {@ai_translation_progress || 0} / {@ai_translation_total || 0} + <%= if @ai_translation_total && @ai_translation_total > 0 do %> + {@ai_translation_progress || 0} / {@ai_translation_total} + <% else %> + {gettext("Starting...")} + <% end %>
    - - + <%= if @ai_translation_total && @ai_translation_total > 0 do %> + + + <% else %> + + <% end %>
    <% end %> From 6af64c321a84546df145b1bf8d69d7e4a85287f5 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 17 Mar 2026 21:08:44 +0200 Subject: [PATCH 12/15] Fix PubSub subscription mismatch for translation and version events The editor subscribed to translation/version topics using post.slug only, but the worker broadcasts using slug || uuid. Timestamp-mode posts (no slug) never received translation events because the topic didn't match. - Add PubSub.broadcast_id/1 as the single source of truth for post identifier resolution (slug || uuid) - Update collaborative subscriptions to use broadcast_id - Update translation worker to use broadcast_id - Fix translation_created handler to pass current_language - Remove unused default on re_read_post (compiler enforces language arg) - Add tests for broadcast_id and topic consistency Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/pubsub.ex | 15 ++++ lib/modules/publishing/web/editor.ex | 4 +- .../publishing/web/editor/collaborative.ex | 12 ++-- .../workers/translate_post_worker.ex | 2 +- .../publishing/pubsub_broadcast_id_test.exs | 71 +++++++++++++++++++ 5 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 test/modules/publishing/pubsub_broadcast_id_test.exs diff --git a/lib/modules/publishing/pubsub.ex b/lib/modules/publishing/pubsub.ex index 493e6d6d3..0a3b626d6 100644 --- a/lib/modules/publishing/pubsub.ex +++ b/lib/modules/publishing/pubsub.ex @@ -18,6 +18,21 @@ defmodule PhoenixKit.Modules.Publishing.PubSub do @topic_editor_forms "publishing:editor_forms" @topic_groups "publishing:groups" + # ============================================================================ + # Post Identifier Resolution + # ============================================================================ + + @doc """ + Returns the broadcast identifier for a post. + + Uses slug when available, falls back to uuid. This identifier is used + for PubSub topic construction and must be consistent between broadcasters + (e.g. translation worker) and subscribers (e.g. editor). + """ + def broadcast_id(post) do + post[:slug] || post[:uuid] + end + # ============================================================================ # Group-Level Updates (group creation/deletion) # ============================================================================ diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex index b958eae35..de7b29999 100644 --- a/lib/modules/publishing/web/editor.ex +++ b/lib/modules/publishing/web/editor.ex @@ -1320,7 +1320,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do def handle_info({:translation_created, group_slug, post_identifier, language}, socket) do if socket.assigns[:group_slug] == group_slug && post_matches?(socket, post_identifier) do - case re_read_post(socket) do + case re_read_post(socket, socket.assigns[:current_language]) do {:ok, updated_post} -> socket = socket @@ -1553,7 +1553,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do assign(socket, :autosave_timer, timer_ref) end - defp re_read_post(socket, language \\ nil) do + defp re_read_post(socket, language) do case socket.assigns[:post] do nil -> {:error, :no_post} %{uuid: nil} -> {:error, :no_uuid} diff --git a/lib/modules/publishing/web/editor/collaborative.ex b/lib/modules/publishing/web/editor/collaborative.ex index 68a7e1e6c..1a44ff109 100644 --- a/lib/modules/publishing/web/editor/collaborative.ex +++ b/lib/modules/publishing/web/editor/collaborative.ex @@ -184,9 +184,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Collaborative do end defp subscribe_to_post_translations(socket) do - case socket.assigns[:post] && socket.assigns.post[:slug] do - post_slug when is_binary(post_slug) -> - PublishingPubSub.subscribe_to_post_translations(socket.assigns.group_slug, post_slug) + case PublishingPubSub.broadcast_id(socket.assigns[:post]) do + id when is_binary(id) -> + PublishingPubSub.subscribe_to_post_translations(socket.assigns.group_slug, id) _ -> :ok @@ -194,9 +194,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Collaborative do end defp subscribe_to_post_versions(socket) do - case socket.assigns[:post] && socket.assigns.post[:slug] do - post_slug when is_binary(post_slug) -> - PublishingPubSub.subscribe_to_post_versions(socket.assigns.group_slug, post_slug) + case PublishingPubSub.broadcast_id(socket.assigns[:post]) do + id when is_binary(id) -> + PublishingPubSub.subscribe_to_post_versions(socket.assigns.group_slug, id) _ -> :ok diff --git a/lib/modules/publishing/workers/translate_post_worker.ex b/lib/modules/publishing/workers/translate_post_worker.ex index f56100c09..1f80a67cf 100644 --- a/lib/modules/publishing/workers/translate_post_worker.ex +++ b/lib/modules/publishing/workers/translate_post_worker.ex @@ -184,7 +184,7 @@ defmodule PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker do ) do group_slug = source_post.group total = length(target_languages) - broadcast_id = source_post.slug || source_post[:uuid] + broadcast_id = PublishingPubSub.broadcast_id(source_post) # Broadcast that translation has started PublishingPubSub.broadcast_translation_started(group_slug, broadcast_id, target_languages) diff --git a/test/modules/publishing/pubsub_broadcast_id_test.exs b/test/modules/publishing/pubsub_broadcast_id_test.exs new file mode 100644 index 000000000..b526747d4 --- /dev/null +++ b/test/modules/publishing/pubsub_broadcast_id_test.exs @@ -0,0 +1,71 @@ +defmodule PhoenixKit.Modules.Publishing.PubSubBroadcastIdTest do + use ExUnit.Case, async: true + + alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub + + # ============================================================================ + # broadcast_id/1 + # ============================================================================ + + describe "broadcast_id/1" do + test "returns slug when present" do + post = %{slug: "my-post", uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"} + assert PublishingPubSub.broadcast_id(post) == "my-post" + end + + test "falls back to uuid when slug is nil" do + post = %{slug: nil, uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"} + assert PublishingPubSub.broadcast_id(post) == "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18" + end + + test "falls back to uuid when slug key is missing" do + post = %{uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"} + assert PublishingPubSub.broadcast_id(post) == "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18" + end + + test "returns nil when both slug and uuid are nil" do + post = %{slug: nil, uuid: nil} + assert PublishingPubSub.broadcast_id(post) == nil + end + + test "returns nil for nil post" do + assert PublishingPubSub.broadcast_id(nil) == nil + end + + test "prefers slug over uuid" do + post = %{slug: "hello-world", uuid: "019cfcf7-0000-0000-0000-000000000000"} + assert PublishingPubSub.broadcast_id(post) == "hello-world" + end + end + + # ============================================================================ + # Topic consistency + # ============================================================================ + + describe "topic consistency" do + test "subscription and broadcast use the same topic for slug-mode posts" do + post = %{slug: "my-post", uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"} + broadcast_id = PublishingPubSub.broadcast_id(post) + + # The subscription topic should match what the worker would broadcast to + topic = PublishingPubSub.post_translations_topic("blog", broadcast_id) + assert topic == "publishing:blog:post:my-post:translations" + end + + test "subscription and broadcast use the same topic for timestamp-mode posts (no slug)" do + post = %{slug: nil, uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"} + broadcast_id = PublishingPubSub.broadcast_id(post) + + topic = PublishingPubSub.post_translations_topic("news", broadcast_id) + assert topic == "publishing:news:post:019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18:translations" + end + + test "version topic uses same broadcast_id pattern" do + post = %{slug: nil, uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"} + broadcast_id = PublishingPubSub.broadcast_id(post) + + topic = PublishingPubSub.post_versions_topic("news", broadcast_id) + assert topic == "publishing:news:post:019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18:versions" + end + end +end From 09a4d6b94f53d2276e1fed1e3537016da07d13a3 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 17 Mar 2026 21:29:49 +0200 Subject: [PATCH 13/15] Add Open Graph and Twitter Card meta tags for public publishing pages Render og:title, og:description, og:image, og:url, og:locale, og:type, canonical link, and Twitter Card tags in the root layout when @og assign is present. Build OG data from post metadata, description, and featured image in the publishing controller for posts, versioned posts, date-only URLs, and group listings. Use conn.scheme/host/port for base URL instead of Endpoint.url() to avoid compile-time dependency on the parent app endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/controller.ex | 51 +++++++++++++++++++ .../publishing/web/templates/show.html.heex | 1 - .../components/layouts/root.html.heex | 27 ++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/lib/modules/publishing/web/controller.ex b/lib/modules/publishing/web/controller.ex index d33f3e6cf..66c865e71 100644 --- a/lib/modules/publishing/web/controller.ex +++ b/lib/modules/publishing/web/controller.ex @@ -31,6 +31,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do alias PhoenixKit.Modules.Publishing.Web.Controller.Listing alias PhoenixKit.Modules.Publishing.Web.Controller.PostRendering alias PhoenixKit.Modules.Publishing.Web.Controller.Routing + alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML alias PhoenixKit.Settings # ============================================================================ @@ -160,6 +161,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do defp handle_group_listing(conn, group_slug, language) do case Listing.render_group_listing(conn, group_slug, language, conn.params) do {:ok, assigns} -> + listing_url = PublishingHTML.group_listing_path(assigns.current_language, group_slug) + + base_url = + "#{conn.scheme}://#{conn.host}#{if conn.port in [80, 443], do: "", else: ":#{conn.port}"}" + conn |> assign(:page_title, assigns.page_title) |> assign(:group, assigns.group) @@ -171,6 +177,12 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do |> assign(:total_count, assigns.total_count) |> assign(:total_pages, assigns.total_pages) |> assign(:breadcrumbs, assigns.breadcrumbs) + |> assign(:og, %{ + title: assigns.group["name"], + url: base_url <> listing_url, + locale: assigns.current_language, + type: "website" + }) |> render(:index) {:redirect, url} -> @@ -188,6 +200,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do defp handle_post(conn, group_slug, identifier, language) do case PostRendering.render_post(conn, group_slug, identifier, language) do {:ok, assigns} -> + canonical_url = + PublishingHTML.build_post_url(group_slug, assigns.post, assigns.current_language) + conn |> assign(:page_title, assigns.page_title) |> assign(:group_slug, assigns.group_slug) @@ -197,6 +212,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do |> assign(:translations, assigns.translations) |> assign(:breadcrumbs, assigns.breadcrumbs) |> assign(:version_dropdown, assigns.version_dropdown) + |> assign(:og, build_og_data(conn, assigns.post, canonical_url, assigns.current_language)) |> render(:show) {:redirect, url} -> @@ -228,6 +244,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do |> assign(:is_live_version, assigns.is_live_version) |> assign(:version, assigns.version) |> assign(:version_dropdown, assigns.version_dropdown) + |> assign( + :og, + build_og_data(conn, assigns.post, assigns.canonical_url, assigns.current_language) + ) |> render(:show) {:error, reason} -> @@ -238,6 +258,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do defp handle_date_only_url(conn, group_slug, date, language) do case PostRendering.handle_date_only_url(conn, group_slug, date, language) do {:ok, assigns} -> + canonical_url = + PublishingHTML.build_post_url(group_slug, assigns.post, assigns.current_language) + conn |> assign(:page_title, assigns.page_title) |> assign(:group_slug, assigns.group_slug) @@ -247,6 +270,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do |> assign(:translations, assigns.translations) |> assign(:breadcrumbs, assigns.breadcrumbs) |> assign(:version_dropdown, assigns.version_dropdown) + |> assign(:og, build_og_data(conn, assigns.post, canonical_url, assigns.current_language)) |> render(:show) {:redirect, url} -> @@ -292,4 +316,31 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do defp set_gettext_locale(language) do Gettext.put_locale(PhoenixKitWeb.Gettext, language) end + + defp build_og_data(conn, post, canonical_url, language) do + seo = Map.get(post.metadata, :seo) || Map.get(post, :seo) || %{} + description = seo["og_description"] || Map.get(post.metadata, :description) + image = seo["og_image"] || PublishingHTML.featured_image_url(post, "large") + + base_url = + "#{conn.scheme}://#{conn.host}#{if conn.port in [80, 443], do: "", else: ":#{conn.port}"}" + + %{ + title: seo["og_title"] || post.metadata.title, + description: description, + image: absolute_url(base_url, image), + url: absolute_url(base_url, canonical_url), + locale: language, + type: "article" + } + end + + defp absolute_url(_base, nil), do: nil + defp absolute_url(_base, ""), do: nil + + defp absolute_url(base, url) when is_binary(url) do + if String.starts_with?(url, "http"), do: url, else: base <> url + end + + defp absolute_url(_base, _url), do: nil end diff --git a/lib/modules/publishing/web/templates/show.html.heex b/lib/modules/publishing/web/templates/show.html.heex index 4d91536e5..35bbbe113 100644 --- a/lib/modules/publishing/web/templates/show.html.heex +++ b/lib/modules/publishing/web/templates/show.html.heex @@ -4,7 +4,6 @@ current_path={@conn.request_path} phoenix_kit_current_scope={assigns[:phoenix_kit_current_scope]} > - <%!-- TODO: Add SEO meta tags (description, canonical) when layout system supports meta_tags slot --%>
    <%!-- Breadcrumb Navigation --%>