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) -> 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/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/components/language_switcher.ex b/lib/modules/publishing/web/components/language_switcher.ex index a682f0fff..728303f53 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 %> - "bg-base-content/10 text-base-content/50 font-semibold cursor-pointer" + "bg-base-content/30 text-base-content/50 font-semibold cursor-pointer" (!enabled or !known) and exists -> "text-base-content/40 hover:bg-base-200/50 cursor-pointer" is_current -> - "bg-primary/10 text-primary font-semibold" + "bg-primary/30 text-primary font-semibold" !exists && show_add -> "text-success hover:bg-success/10 cursor-pointer" 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/controller/listing.ex b/lib/modules/publishing/web/controller/listing.ex index 57c1f2fee..13064bfc0 100644 --- a/lib/modules/publishing/web/controller/listing.ex +++ b/lib/modules/publishing/web/controller/listing.ex @@ -182,8 +182,13 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do lang_excerpts = post[:language_excerpts] || %{} metadata = post[:metadata] || %{} - title = Map.get(lang_titles, language, metadata[:title] || Constants.default_title()) - excerpt = Map.get(lang_excerpts, language) + # Language map keys are full dialect codes (e.g., "en-US") but the + # canonical_language may be a base code (e.g., "en") when only one + # dialect is enabled. Try exact match first, then match by base code. + resolved_key = resolve_language_key(language, Map.keys(lang_titles)) + + title = Map.get(lang_titles, resolved_key, metadata[:title] || Constants.default_title()) + excerpt = Map.get(lang_excerpts, resolved_key) post |> Map.update(:metadata, %{title: title}, &Map.put(&1, :title, title)) @@ -192,6 +197,17 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do end) end + # Resolves a display language code to a key in the language map. + # Handles base code ("en") → dialect code ("en-US") matching. + defp resolve_language_key(language, available_keys) do + if language in available_keys do + language + else + base = DialectMapper.extract_base(language) + Enum.find(available_keys, language, fn key -> DialectMapper.extract_base(key) == base end) + end + end + # ============================================================================ # Filtering Functions # ============================================================================ 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/editor.ex b/lib/modules/publishing/web/editor.ex index 0edfdac8b..de7b29999 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) @@ -955,7 +953,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 @@ -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 @@ -1330,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 @@ -1489,20 +1479,13 @@ 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) - 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) @@ -1534,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) 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.html.heex b/lib/modules/publishing/web/editor.html.heex index 3fbe0eaa4..fa8c51bd5 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"); @@ -159,14 +151,16 @@ )}
- + <%= unless @is_new_post do %> + + <% end %> <%= if @form["status"] == "published" && @public_url do %> - {@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 %> @@ -692,86 +694,73 @@
- <%!-- Title field --%> -
- -

- <%= if @is_primary_language do %> - {gettext("Auto-populated from the first heading. Edit to override.")} - <% 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} @@ -810,7 +799,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/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/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..b66272451 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)}") @@ -710,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) @@ -756,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/lib/modules/publishing/web/html.ex b/lib/modules/publishing/web/html.ex index 94e99b039..bc198221a 100644 --- a/lib/modules/publishing/web/html.ex +++ b/lib/modules/publishing/web/html.ex @@ -6,6 +6,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do alias PhoenixKit.Config alias PhoenixKit.Modules.Languages + alias PhoenixKit.Modules.Languages.DialectMapper alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.Constants alias PhoenixKit.Modules.Publishing.Renderer @@ -115,7 +116,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do # language_slugs map from cache map_size(Map.get(post, :language_slugs, %{})) > 0 -> - Map.get(post.language_slugs, language, post.slug) + # Language map keys are full dialect codes (e.g., "en-US") but the + # language param may be a base code (e.g., "en") when only one + # dialect is enabled. Try exact match first, then match by base code. + resolved_key = resolve_language_key(language, Map.keys(post.language_slugs)) + Map.get(post.language_slugs, resolved_key, post.slug) # metadata.url_slug is_map(Map.get(post, :metadata)) and Map.get(post.metadata, :url_slug) not in [nil, ""] -> @@ -127,6 +132,17 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do end end + # Resolves a display language code to a key in the language map. + # Handles base code ("en") → dialect code ("en-US") matching. + defp resolve_language_key(language, available_keys) do + if language in available_keys do + language + else + base = DialectMapper.extract_base(language) + Enum.find(available_keys, language, fn key -> DialectMapper.extract_base(key) == base end) + end + end + @doc """ Builds a public path with explicit date and time (always includes time). Used when redirecting from date-only URLs to full timestamp URLs. 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 || "" %> 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 %>
diff --git a/lib/modules/publishing/web/templates/all_groups.html.heex b/lib/modules/publishing/web/templates/all_groups.html.heex index a912e2b62..97a0ef44c 100644 --- a/lib/modules/publishing/web/templates/all_groups.html.heex +++ b/lib/modules/publishing/web/templates/all_groups.html.heex @@ -18,12 +18,12 @@ 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..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 --%> <%!-- Post Footer --%>
    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/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 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 diff --git a/lib/phoenix_kit_web/components/layouts/root.html.heex b/lib/phoenix_kit_web/components/layouts/root.html.heex index 168dffe5f..10e3cc477 100644 --- a/lib/phoenix_kit_web/components/layouts/root.html.heex +++ b/lib/phoenix_kit_web/components/layouts/root.html.heex @@ -12,6 +12,33 @@ <% end %> + <%= if og = assigns[:og] do %> + + <%= if og[:title] do %> + + + <% end %> + <%= if og[:description] do %> + + + + <% end %> + <%= if og[:image] do %> + + + <% end %> + <%= if og[:url] do %> + + + <% end %> + <%= if og[:locale] do %> + + <% end %> + + <% end %> <.live_title default={PhoenixKit.Settings.get_project_title()} suffix=" · Phoenix Framework" 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 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 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