Packages
phoenix_kit
1.7.80
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/publishing/web/editor.html.heex
<PhoenixKitWeb.Components.LayoutWrapper.app_layout
flash={@flash}
phoenix_kit_current_scope={assigns[:phoenix_kit_current_scope]}
page_title={@page_title}
current_path={PhoenixKit.Utils.Routes.path("/admin/publishing/#{@group_slug}/edit")}
project_title={@project_title}
current_locale={@current_locale}
>
<% nonce = assigns[:script_csp_nonce] || assigns[:csp_nonce] || "" %>
<% edit_disabled? = @readonly? or @translation_locked? %>
<%!-- Unsaved changes tracking and navigation protection --%>
<script nonce={nonce}>
(function() {
// Track unsaved changes state
window.editorUnsavedChanges = false;
// Save immediately when user switches tabs or minimizes
document.addEventListener("visibilitychange", function() {
if (document.hidden && window.editorUnsavedChanges) {
// Trigger LiveView save event via hidden button
const saveBtn = document.querySelector("[phx-click='save']");
if (saveBtn) saveBtn.click();
}
});
// Browser exit protection — save before leaving
window.addEventListener("beforeunload", function(e) {
if (window.editorUnsavedChanges) {
// Try to trigger save (may not complete before page unloads)
const saveBtn = document.querySelector("[phx-click='save']");
if (saveBtn) saveBtn.click();
e.preventDefault();
e.returnValue = "";
return "";
}
});
// Intercept navigation links — save first, then navigate
document.addEventListener("click", function(e) {
if (window.editorUnsavedChanges) {
const link = e.target.closest("a[href], a[data-phx-link]");
if (link && !link.hasAttribute("data-confirm")) {
const href = link.getAttribute("href") || link.getAttribute("data-phx-link");
if (href && !href.startsWith("http") && !href.startsWith("#")) {
e.preventDefault();
e.stopPropagation();
// Save first, then ask to confirm navigation
const saveBtn = document.querySelector("[phx-click='save']");
if (saveBtn) saveBtn.click();
// Small delay to let save complete, then confirm
setTimeout(function() {
document.getElementById("confirm-cancel-btn").click();
}, 100);
}
}
}
}, true);
// Listen for changes status from LiveView
window.addEventListener("phx:changes-status", function(e) {
window.editorUnsavedChanges = e.detail.has_changes;
});
// Listen for confirm navigation event
window.addEventListener("phx:confirm-navigation", function(_e) {
if (confirm("You have unsaved changes. Are you sure you want to leave?")) {
document.getElementById("confirm-cancel-btn").click();
}
});
// Listen for slug update event from LiveView
window.addEventListener("phx:update-slug", function(e) {
const slugInput = document.getElementById("slug-input");
if (slugInput && e.detail.slug) {
slugInput.value = e.detail.slug;
}
});
// 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");
if (urlSlugInput && e.detail.url_slug) {
urlSlugInput.value = e.detail.url_slug;
}
});
// Listen for exec-js events from LiveView (for media insertion)
window.addEventListener("phx:exec-js", (e) => {
try {
eval(e.detail.js);
} catch (err) {
console.error("[ContentEditor] Error executing JS:", err);
}
});
// Listen for video prompt event
window.addEventListener("phx:prompt-and-insert", (e) => {
const url = window.prompt("Enter YouTube URL:", "https://youtu.be/dQw4w9WgXcQ");
if (url && url.trim()) {
window.publishingEditorInsertMedia(null, 'video', url.trim());
}
});
// Function to insert standard markdown media syntax at cursor position
window.publishingEditorInsertMedia = function(fileUrl, mediaType, videoUrl) {
const textarea = document.getElementById('content-editor-textarea');
if (!textarea) return;
// Build standard markdown syntax: 
let template;
if (mediaType === 'image' && fileUrl) {
template = '\n\n';
} else if (mediaType === 'video') {
const url = videoUrl || fileUrl || '';
template = '\n\n';
} else {
return;
}
const start = textarea.selectionStart || 0;
const currentValue = textarea.value;
const newValue = currentValue.substring(0, start) + template + currentValue.substring(start);
textarea.value = newValue;
// Move cursor after inserted text
const newPos = start + template.length;
textarea.selectionStart = textarea.selectionEnd = newPos;
textarea.focus();
// Trigger keyup event to update LiveView (matches phx-keyup binding on textarea)
textarea.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true }));
};
})();
</script>
<%!-- Hidden confirmation button for JavaScript --%>
<button
id="confirm-cancel-btn"
type="button"
phx-click="cancel"
class="hidden"
aria-hidden="true"
>
</button>
<div class="container mx-auto px-4 py-6 space-y-6">
<div class="flex flex-wrap items-center justify-between gap-2">
<button type="button" class="btn btn-ghost btn-sm" phx-click="back_to_list">
<.icon name="hero-arrow-left" class="w-4 h-4 mr-2" /> {gettext("Back to %{group}",
group: @group_name || gettext("Group")
)}
</button>
<div class="flex items-center gap-2">
<%= unless @is_new_post do %>
<button
type="button"
class="btn btn-outline btn-xs sm:btn-sm shadow-none"
phx-click="preview"
>
<.icon name="hero-eye" class="w-4 h-4 sm:mr-1" />
<span class="hidden sm:inline">{gettext("Preview")}</span>
</button>
<% end %>
<%= if @form["status"] == "published" && @public_url do %>
<a
href={if @has_pending_changes, do: "#", else: @public_url}
target="_blank"
class={[
"btn btn-outline btn-xs sm:btn-sm shadow-none",
!@has_pending_changes && "btn-success",
@has_pending_changes && "btn-disabled pointer-events-none opacity-60"
]}
aria-disabled={@has_pending_changes}
tabindex={if @has_pending_changes, do: "-1", else: "0"}
title={
if(@has_pending_changes,
do: "Save the post before viewing the public page",
else: "View this post on the public site"
)
}
>
<.icon name="hero-globe-alt" class="w-4 h-4 sm:mr-1" />
<span class="hidden sm:inline">View Public</span>
</a>
<% end %>
</div>
</div>
<%!-- Language and Version Switchers --%>
<div class="flex flex-col gap-2">
<%!-- Language Switcher --%>
<%= if length(@all_enabled_languages) > 1 or not @current_language_enabled or not @current_language_known do %>
<% all_languages =
build_editor_languages(
@post,
@all_enabled_languages,
@current_language
) %>
<% primary_lang = Enum.find(all_languages, & &1.is_primary) %>
<% other_languages = Enum.reject(all_languages, & &1.is_primary) %>
<%!-- Other translations --%>
<div class="flex items-start gap-2">
<span class="text-xs font-medium text-base-content/60 shrink-0 pt-1">
{gettext("Language:")}
</span>
<.language_switcher
languages={other_languages}
current_language={@current_language}
show_status={true}
show_add={true}
on_click_js={&switch_lang_js(&1, @current_language)}
size={:sm}
/>
</div>
<%!-- Primary language --%>
<%= if primary_lang do %>
<div class="flex items-center gap-2 flex-wrap">
<span
class="text-xs font-medium text-base-content/60 tooltip tooltip-bottom cursor-help z-50"
data-tip={
gettext(
"Other languages inherit publication status and defaults from this version."
)
}
>
{gettext("Primary:")}
</span>
<button
type="button"
phx-click={
switch_lang_js(
primary_lang.code,
@current_language
)
}
class={[
"inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg cursor-pointer transition-colors",
if(primary_lang.is_current,
do: "bg-primary/20 border border-primary/40 ring-2 ring-primary/30",
else: "hover:bg-base-200"
)
]}
title={primary_lang.name}
>
<span class={[
"rounded-full inline-block w-2.5 h-2.5",
cond do
not primary_lang.exists -> "bg-base-content/20"
primary_lang.status == "published" -> "bg-success"
primary_lang.status == "draft" -> "bg-warning"
primary_lang.status == "archived" -> "bg-base-content/40"
true -> "bg-base-content/20"
end
]}>
</span>
<span class={[
"text-sm font-bold uppercase",
cond do
not primary_lang.exists -> "text-base-content/20"
primary_lang.status == "published" -> "text-success"
primary_lang.status == "draft" -> "text-warning"
primary_lang.status == "archived" -> "text-base-content/40"
true -> "text-base-content/20"
end
]}>
{String.upcase(primary_lang.display_code)}
</span>
<span class="text-sm text-base-content/70">
{primary_lang.name}
</span>
</button>
</div>
<% end %>
<% end %>
<%!-- Version Switcher (for versioned posts in both slug and timestamp modes) --%>
<%= if !@is_new_post && @post do %>
<div class="flex items-center gap-1.5 flex-wrap">
<%= if length(@available_versions) > 1 do %>
<span class="text-xs font-medium text-base-content/60">{gettext("Version:")}</span>
<.publishing_version_switcher
versions={@available_versions}
version_statuses={@version_statuses}
version_dates={@version_dates}
current_version={@current_version}
on_click="switch_version"
size={:sm}
/>
<% else %>
<span class="text-xs font-medium text-base-content/60">
{gettext("Version:")} v{@current_version}
</span>
<% end %>
<%!-- New Version Button --%>
<button
type="button"
class={"btn btn-ghost btn-xs gap-1 #{if edit_disabled?, do: "btn-disabled opacity-60"}"}
phx-click="open_new_version_modal"
disabled={edit_disabled?}
>
<.icon name="hero-plus" class="w-3 h-3" />
{gettext("New Version")}
</button>
<%!-- AI Translation Button --%>
<%= if @ai_enabled do %>
<button
type="button"
class="btn btn-ghost btn-xs gap-1"
phx-click="toggle_ai_translation"
>
<.icon name="hero-language" class="w-3 h-3" />
{gettext("AI Translate")}
</button>
<% end %>
</div>
<% end %>
</div>
<%!-- Version locking banner removed - with variant versioning all versions are editable --%>
<%!-- Spectator mode banner - someone else is editing or lock expired --%>
<%= if @readonly? do %>
<div class="alert alert-warning shadow-sm">
<.icon name="hero-eye" class="w-5 h-5" />
<div class="flex-1">
<%= if assigns[:lock_released_by_timeout] do %>
<span class="font-medium">{gettext("Session paused:")}</span>
<span>
{gettext(
"Your editing lock expired due to inactivity. Start typing or click Save to resume editing."
)}
</span>
<% else %>
<span class="font-medium">{gettext("View only mode:")}</span>
<span>
<%= if @lock_owner_user do %>
{gettext(
"%{email} is currently editing this post. You can view but not make changes.",
email: @lock_owner_user.email
)}
<% else %>
{gettext(
"Another user is currently editing this post. You can view but not make changes."
)}
<% end %>
</span>
<% end %>
</div>
</div>
<% end %>
<%!-- Auto-version creation banner removed - users now explicitly create new versions via the "New Version" button --%>
<%!-- Warning for disabled or unknown language --%>
<%= if not @current_language_enabled or not @current_language_known do %>
<div class="alert alert-warning shadow-sm">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<div>
<%= if not @current_language_known do %>
<span class="font-medium">{gettext("Unknown language:")}</span>
<span>
{gettext(
"This language (%{code}) doesn't match a recognized language code. The publishing status will still be respected.",
code: @current_language
)}
</span>
<% else %>
<span class="font-medium">{gettext("Disabled language:")}</span>
<span>
{gettext(
"This language (%{code}) is no longer enabled in the Languages module. The publishing status will still be respected for legacy content.",
code: @current_language
)}
</span>
<% end %>
</div>
</div>
<% end %>
<%!-- Primary Language Needs Update Warning (shown on ALL languages when update needed) --%>
<% needs_update =
not @is_new_post and length(@all_enabled_languages) > 1 and
@post_primary_language_status != {:ok, :current} %>
<%= if needs_update do %>
<div class="flex items-center gap-3 px-4 py-2.5 rounded-lg border bg-gradient-to-r from-warning/10 to-amber-500/10 border-warning/30">
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white shadow-sm bg-gradient-to-br from-warning to-amber-500">
<.icon name="hero-exclamation-triangle" class="w-4 h-4" />
</div>
<div class="flex-1">
<span class="text-sm font-medium text-warning">
{gettext("Primary Language Needs Update")}
</span>
<p class="text-xs text-base-content/60">
{gettext(
"The global primary language setting has changed to %{language}. Update this post or create a new version.",
language: @global_primary_language_name
)}
</p>
</div>
<button
type="button"
class="btn btn-warning btn-sm"
phx-click="update_primary_language"
phx-disable-with={gettext("Updating...")}
>
<.icon name="hero-arrow-path" class="w-4 h-4" />
{gettext("Update")}
</button>
</div>
<% end %>
<%!-- Translation in progress lock banner --%>
<%= if @translation_locked? do %>
<div class="alert shadow-sm border border-primary/30 bg-primary/5">
<span class="loading loading-spinner loading-sm text-primary"></span>
<div>
<span class="font-medium">{gettext("Translation in progress")}</span>
<span class="text-sm text-base-content/70">
{gettext(
"Editing is paused while AI translates this content. It will unlock automatically when finished."
)}
</span>
</div>
</div>
<% end %>
<%!-- AI Translation Modal --%>
<%= if @ai_enabled and not @is_new_post do %>
<dialog id="ai-translation-modal" class={["modal", @show_ai_translation && "modal-open"]}>
<div class="modal-box max-w-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg flex items-center gap-2">
<.icon name="hero-language" class="w-5 h-5 text-primary" />
{gettext("AI Translation")}
</h3>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost"
phx-click="toggle_ai_translation"
>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
</div>
<div class="space-y-4">
<p class="text-sm text-base-content/70">
<%= if @is_primary_language do %>
{gettext(
"Automatically translate this post to other languages using AI. The translation will be queued as a background job."
)}
<% else %>
{gettext(
"Translate the %{source} post to %{target} using AI. The translation will be queued as a background job.",
source: @primary_language_name,
target: @current_language_name
)}
<% end %>
</p>
<%!-- Endpoint Selection --%>
<div class="space-y-1">
<form phx-change="select_ai_endpoint">
<select
class="select select-bordered select-sm w-full"
name="endpoint_uuid"
>
<option value="">{gettext("Select an endpoint...")}</option>
<%= for {id, name} <- @ai_endpoints do %>
<option value={id} selected={@ai_selected_endpoint_uuid == id}>
{name}
</option>
<% end %>
</select>
</form>
<.link
navigate={PhoenixKit.Utils.Routes.path("/admin/ai/endpoints")}
class="text-xs link link-primary"
>
{gettext("Manage Endpoints")}
</.link>
</div>
<%!-- Prompt Selection --%>
<div class="space-y-1">
<form phx-change="select_ai_prompt">
<select
class="select select-bordered select-sm w-full"
name="prompt_uuid"
>
<option value="">{gettext("Select a prompt...")}</option>
<%= for {id, name} <- @ai_prompts do %>
<option value={id} selected={@ai_selected_prompt_uuid == id}>
{name}
</option>
<% end %>
</select>
</form>
<div class="flex items-center gap-2">
<.link
navigate={PhoenixKit.Utils.Routes.path("/admin/ai/prompts")}
class="text-xs link link-primary"
>
{gettext("Manage Prompts")}
</.link>
<%= unless @ai_default_prompt_exists do %>
<button
type="button"
class="btn btn-outline btn-xs gap-1"
phx-click="generate_default_translation_prompt"
>
<.icon name="hero-sparkles" class="w-3 h-3" />
{gettext("Generate Default Prompt")}
</button>
<% end %>
</div>
</div>
<%!-- Translation Status --%>
<%= if @ai_translation_status in [:enqueued, :in_progress, :completed] do %>
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-base-content/70 flex items-center gap-2">
<%= if @ai_translation_status == :completed do %>
<.icon name="hero-check-circle" class="w-4 h-4 text-success" />
{gettext("Complete")}
<% else %>
<span class="loading loading-spinner loading-xs"></span>
<%= if @ai_translation_languages != [] do %>
{gettext("Translating to %{languages}...",
languages: format_language_list(@ai_translation_languages)
)}
<% else %>
{gettext("Translating...")}
<% end %>
<% end %>
</span>
<span class="font-medium">
<%= if @ai_translation_total && @ai_translation_total > 0 do %>
{@ai_translation_progress || 0} / {@ai_translation_total}
<% else %>
{gettext("Starting...")}
<% end %>
</span>
</div>
<%= if @ai_translation_total && @ai_translation_total > 0 do %>
<progress
class={[
"progress w-full",
@ai_translation_status == :completed && "progress-success",
@ai_translation_status != :completed && "progress-primary"
]}
value={@ai_translation_progress || 0}
max={@ai_translation_total}
>
</progress>
<% else %>
<progress class="progress progress-primary w-full"></progress>
<% end %>
</div>
<% end %>
<%!-- Action Buttons --%>
<div class="flex flex-wrap gap-3">
<%= if @is_primary_language do %>
<button
type="button"
class={"btn btn-primary btn-sm #{if @ai_selected_endpoint_uuid == nil or @ai_selected_prompt_uuid == nil or @ai_translation_status in [:enqueued, :in_progress], do: "btn-disabled"}"}
phx-click="translate_to_all_languages"
disabled={
@ai_selected_endpoint_uuid == nil or
@ai_selected_prompt_uuid == nil or
@ai_translation_status in [:enqueued, :in_progress]
}
>
<.icon name="hero-language" class="w-4 h-4" />
{gettext("Translate to All Languages")}
</button>
<button
type="button"
class={"btn btn-outline btn-sm #{if @ai_selected_endpoint_uuid == nil or @ai_selected_prompt_uuid == nil or @ai_translation_status in [:enqueued, :in_progress], do: "btn-disabled"}"}
phx-click="translate_missing_languages"
disabled={
@ai_selected_endpoint_uuid == nil or
@ai_selected_prompt_uuid == nil or
@ai_translation_status in [:enqueued, :in_progress]
}
>
<.icon name="hero-plus" class="w-4 h-4" />
{gettext("Translate Missing Only")}
</button>
<% else %>
<button
type="button"
class={"btn btn-primary btn-sm #{if @ai_selected_endpoint_uuid == nil or @ai_selected_prompt_uuid == nil or @ai_translation_status in [:enqueued, :in_progress], do: "btn-disabled"}"}
phx-click="translate_to_this_language"
disabled={
@ai_selected_endpoint_uuid == nil or
@ai_selected_prompt_uuid == nil or
@ai_translation_status in [:enqueued, :in_progress]
}
>
<.icon name="hero-language" class="w-4 h-4" />
{gettext("Translate to This Language")}
</button>
<% end %>
</div>
<%!-- Info --%>
<div class="text-xs text-base-content/50 space-y-1">
<%= if @is_primary_language do %>
<p>
<.icon name="hero-information-circle" class="w-3 h-3 inline" />
{gettext(
"\"Translate to All\" will create or overwrite translations for all enabled languages."
)}
</p>
<p>
<.icon name="hero-information-circle" class="w-3 h-3 inline" />
{gettext(
"\"Translate Missing\" will only create translations for languages that don't have one yet."
)}
</p>
<% else %>
<p>
<.icon name="hero-information-circle" class="w-3 h-3 inline" />
{gettext(
"This will translate the %{source} content to %{target}, overwriting any existing content.",
source: @primary_language_name,
target: @current_language_name
)}
</p>
<% end %>
</div>
</div>
</div>
<div class="modal-backdrop" phx-click="toggle_ai_translation"></div>
</dialog>
<% end %>
<%!-- Skeleton placeholders for language switching.
NOT hidden by default — the fields div hides them on mount via phx-mounted.
IDs include @current_language so morphdom treats them as new elements,
ensuring a fresh visible skeleton appears during each language switch.
Uses bg-base-content/10 instead of DaisyUI skeleton class because
the skeleton class depends on --color-base-300 which resolves to white
in some PhoenixKit themes. --%>
<div
id={"editor-skeletons-#{@current_language}"}
data-translatable="skeletons"
class={unless @editor_loading, do: "hidden"}
>
<div class="flex flex-col lg:flex-row gap-6 animate-pulse">
<div class="flex-1 space-y-4 p-6">
<div class="bg-base-200 h-12 w-full rounded-lg"></div>
<div class="flex items-center justify-between">
<div class="bg-base-200 h-6 w-32 rounded"></div>
<div class="bg-base-200 h-6 w-24 rounded"></div>
</div>
<div class="bg-base-200 h-[480px] w-full rounded-lg"></div>
</div>
<div class="lg:w-80 space-y-4 p-6">
<div class="space-y-2">
<div class="bg-base-200 h-4 w-20 rounded"></div>
<div class="bg-base-200 h-10 w-full rounded-lg"></div>
<div class="bg-base-200 h-3 w-48 rounded"></div>
</div>
<div class="space-y-2">
<div class="bg-base-200 h-4 w-32 rounded"></div>
<div class="bg-base-200 h-40 w-full rounded-lg"></div>
</div>
<div class="space-y-2">
<div class="bg-base-200 h-4 w-16 rounded"></div>
<div class="bg-base-200 h-10 w-full rounded-lg"></div>
</div>
<div class="space-y-2">
<div class="bg-base-200 h-4 w-40 rounded"></div>
<div class="bg-base-200 h-10 w-full rounded-lg"></div>
</div>
</div>
</div>
</div>
<div
id={"editor-fields-#{@current_language}"}
data-translatable="fields"
class={if @editor_loading, do: "hidden"}
>
<.form for={@form} id="publishing-meta" phx-change="update_meta" phx-submit="noop">
<div class="flex flex-col lg:flex-row gap-6">
<%!-- Left column: Title + Content (primary editing area) --%>
<div class="flex-1 space-y-4">
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body space-y-4">
<%!-- Save status and button --%>
<div class="flex flex-wrap items-center justify-end gap-1.5">
<%!-- Other viewers indicator --%>
<%= if @other_viewers != [] do %>
<div
class="tooltip tooltip-bottom"
data-tip={Enum.map_join(@other_viewers, ", ", & &1.user_email)}
>
<span class="badge badge-info badge-sm gap-1">
<.icon name="hero-eye" class="w-3 h-3" />
{ngettext(
"1 other viewing",
"%{count} others viewing",
length(@other_viewers),
count: length(@other_viewers)
)}
</span>
</div>
<% end %>
<%= cond do %>
<% @is_autosaving -> %>
<span class="badge badge-info badge-sm gap-1">
<span class="loading loading-spinner loading-xs"></span>
{gettext("Saving...")}
</span>
<% @has_pending_changes -> %>
<span class="badge badge-warning badge-sm h-auto">
{gettext("Unsaved changes")}
</span>
<% @is_new_post -> %>
<span class="badge badge-ghost badge-sm h-auto">{gettext("New")}</span>
<% true -> %>
<span class="badge badge-success badge-sm gap-1">
<.icon name="hero-check" class="w-3 h-3" />
{gettext("Saved")}
</span>
<% end %>
<% save_disabled = edit_disabled? || @is_autosaving %>
<button
type="button"
phx-click="save"
class={[
"btn btn-primary btn-xs shadow-none gap-1",
save_disabled && "btn-disabled pointer-events-none opacity-60"
]}
disabled={save_disabled}
>
<span class="hidden phx-click-loading:inline-flex items-center gap-1">
<span class="loading loading-spinner loading-2xs"></span>
{gettext("Saving...")}
</span>
<span class="inline-flex items-center gap-1 phx-click-loading:hidden">
<.icon name="hero-arrow-down-tray" class="w-3 h-3" /> {gettext("Save now")}
</span>
</button>
</div>
<%!-- Title field --%>
<input
type="text"
name="title"
id="title-input"
value={@form["title"] || ""}
maxlength="500"
class={"input input-bordered w-full text-2xl font-semibold #{if edit_disabled? or @viewing_older_version, do: "input-disabled bg-base-200"}"}
placeholder={gettext("Post title")}
readonly={edit_disabled? or @viewing_older_version}
/>
<%!-- Markdown Editor Component --%>
<.live_component
module={PhoenixKitWeb.Components.Core.MarkdownEditor}
id="content-editor"
content={@content}
placeholder={gettext("Write your content here...")}
height="480px"
debounce={400}
toolbar={[:image, :video]}
show_formatting_toolbar={not (edit_disabled? or @viewing_older_version)}
protect_navigation={false}
script_nonce={nonce}
readonly={edit_disabled? or @viewing_older_version}
/>
</div>
</div>
</div>
<%!-- Right column: Metadata sidebar --%>
<div class="lg:w-80 space-y-4">
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body space-y-4">
<%= if @group_mode == "slug" do %>
<%= if @is_primary_language do %>
<%!-- Primary language: editable slug used in the post URL --%>
<div>
<label class="label">
<span class="label-text text-sm font-semibold text-base-content">
{gettext("Slug")}
</span>
</label>
<input
type="text"
name="slug"
id="slug-input"
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 title")}
title={
gettext(
"Use lowercase letters, numbers, and hyphens only. No spaces or special characters."
)
}
readonly={edit_disabled? or @viewing_older_version}
/>
<p class="text-xs text-base-content/60 mt-1">
{gettext("Use lowercase letters, numbers, and hyphens only.")}
{gettext("This will be the default URL for all languages.")}
</p>
</div>
<% else %>
<%!-- Translation: URL Slug field for per-language SEO-friendly URLs --%>
<div>
<label class="label">
<span class="label-text text-sm font-semibold text-base-content">
{gettext("URL Slug")}
<span class="text-base-content/60 font-normal ml-1">
({gettext("optional")})
</span>
</span>
</label>
<input
type="text"
name="url_slug"
id="url-slug-input"
value={@form["url_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={@form["slug"] || ""}
title={
gettext(
"Use lowercase letters, numbers, and hyphens only. Leave empty to use the default slug."
)
}
readonly={edit_disabled? or @viewing_older_version}
/>
<p class="text-xs text-base-content/60 mt-1">
{gettext(
"Custom URL for this language. Leave empty to use default: %{slug}",
slug: @form["slug"]
)}
</p>
<p class="text-xs text-base-content/50 mt-0.5">
{gettext("Preview: /%{language}/%{group}/%{slug}",
language: @current_language,
group: @group_slug,
slug:
if(@form["url_slug"] != "",
do: @form["url_slug"],
else: @form["slug"]
)
)}
</p>
</div>
<% end %>
<% end %>
<div>
<label class="label">
<span class="label-text text-sm font-semibold text-base-content">
{gettext("Featured Image")}
</span>
</label>
<%= if preview_url = featured_image_preview_url(@form["featured_image_uuid"]) do %>
<%!-- Image Preview with Actions --%>
<div class="space-y-3">
<div class="relative group">
<img
src={preview_url}
alt={
Map.get(@post.metadata, :title) ||
Map.get(@post.metadata, "title") ||
gettext("Featured image")
}
class="w-full rounded-lg border-2 border-base-300 object-cover max-h-56"
loading="lazy"
/>
<%!-- Desktop: Hover overlay (hidden when readonly or viewing older version) --%>
<%= if not (edit_disabled? or @viewing_older_version) do %>
<div class="hidden md:flex absolute inset-0 bg-base-content/0 group-hover:bg-base-content/60 transition-all rounded-lg items-center justify-center gap-3 opacity-0 group-hover:opacity-100">
<button
type="button"
phx-click="open_media_selector"
class="btn btn-primary btn-sm shadow-lg"
>
<.icon name="hero-arrow-path" class="w-4 h-4 mr-1" />
{gettext("Change")}
</button>
<button
type="button"
phx-click="clear_featured_image"
class="btn btn-error btn-sm shadow-lg"
>
<.icon name="hero-trash" class="w-4 h-4 mr-1" />
{gettext("Remove")}
</button>
</div>
<% end %>
</div>
<%!-- Mobile: Always visible buttons (hidden when readonly or viewing older version) --%>
<%= if not (edit_disabled? or @viewing_older_version) do %>
<div class="flex md:hidden gap-2">
<button
type="button"
phx-click="open_media_selector"
class="btn btn-primary btn-sm flex-1"
>
<.icon name="hero-arrow-path" class="w-4 h-4 mr-1" />
{gettext("Change")}
</button>
<button
type="button"
phx-click="clear_featured_image"
class="btn btn-error btn-sm flex-1"
>
<.icon name="hero-trash" class="w-4 h-4 mr-1" />
{gettext("Remove")}
</button>
</div>
<% end %>
</div>
<% else %>
<%!-- No Image Selected - Show Upload Area --%>
<button
type="button"
phx-click="open_media_selector"
class={"w-full border-2 border-dashed border-base-300 rounded-lg p-8 transition-all group #{if edit_disabled? or @viewing_older_version, do: "opacity-50 cursor-not-allowed", else: "hover:border-primary hover:bg-primary/5"}"}
disabled={edit_disabled? or @viewing_older_version}
>
<div class="flex flex-col items-center gap-3 text-base-content/60 group-hover:text-primary transition-colors">
<.icon name="hero-photo" class="w-12 h-12" />
<div class="text-center">
<p class="font-semibold text-sm">
{gettext("Select Featured Image")}
</p>
<p class="text-xs mt-1">
{gettext("Click to choose from media library")}
</p>
</div>
</div>
</button>
<% end %>
<%!-- Advanced: Manual ID Entry (Collapsed by default) --%>
<details
class="bg-base-200/50 mt-3 rounded-lg border border-base-300"
open={false}
>
<summary class="cursor-pointer select-none px-3 py-2 rounded-lg hover:bg-base-300/50 transition-colors list-none [&::-webkit-details-marker]:hidden">
<div class="flex items-center gap-1.5">
<.icon
name="hero-chevron-right"
class="w-3 h-3 transition-transform [[open]>&]:rotate-90"
/>
<span class="text-xs font-medium text-base-content/70">
{gettext("Advanced: Manual Media ID")}
</span>
</div>
</summary>
<div class="px-3 pb-3 pt-2">
<input
type="text"
name="featured_image_uuid"
value={@form["featured_image_uuid"]}
class={"input input-bordered input-sm w-full font-mono text-xs #{if edit_disabled? or @viewing_older_version, do: "input-disabled bg-base-200"}"}
placeholder="018e3c4a-9f6b-7890-abcd-ef1234567890"
readonly={edit_disabled? or @viewing_older_version}
/>
<p class="text-xs text-base-content/60 mt-2">
{gettext("Paste a Phoenix Kit Media ID if you know it.")}
</p>
</div>
</details>
</div>
<%!-- Status: editable for primary language, visibility info for translations --%>
<%= if @is_primary_language do %>
<div>
<label class="label">
<span class="label-text text-sm font-semibold text-base-content">
{gettext("Status")}
</span>
</label>
<select
class={"select select-bordered w-full #{if edit_disabled?, do: "select-disabled bg-base-200"}"}
name="status"
disabled={edit_disabled?}
>
<%= if @viewing_older_version do %>
<option
value="published"
selected={@form["status"] in ["draft", "published"]}
>
{gettext("Published")}
</option>
<option value="archived" selected={@form["status"] == "archived"}>
{gettext("Archived")}
</option>
<% else %>
<option value="draft" selected={@form["status"] == "draft"}>
{gettext("Draft")}
</option>
<option value="published" selected={@form["status"] == "published"}>
{gettext("Published")}
</option>
<option value="archived" selected={@form["status"] == "archived"}>
{gettext("Archived")}
</option>
<% end %>
</select>
<p class="text-xs text-base-content/50 mt-1">
{gettext("Translations with content will share this status.")}
</p>
</div>
<% else %>
<% translation_exists = @current_language in (@post[:available_languages] || []) %>
<%= if @form["status"] == "published" do %>
<%= if (@content || "") != "" do %>
<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-success/10 border border-success/20 text-sm text-success">
<.icon name="hero-globe-alt" class="w-4 h-4 shrink-0" />
{gettext("Visible to the public")}
</div>
<% else %>
<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-info/10 border border-info/20 text-sm text-info">
<.icon name="hero-pencil" class="w-4 h-4 shrink-0" />
{gettext("Add content to make this translation visible to the public")}
</div>
<% end %>
<% else %>
<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-base-200 border border-base-300 text-sm text-base-content/60">
<.icon name="hero-eye-slash" class="w-4 h-4 shrink-0" />
{gettext("Not visible — publish the primary language first")}
</div>
<% end %>
<%!-- Clear translation button --%>
<%= if translation_exists do %>
<button
type="button"
phx-click="clear_translation"
class="btn btn-outline btn-error btn-sm w-full gap-2 mt-2"
data-confirm={
gettext(
"Clear the %{language} translation content? You can always add a new translation for this language later.",
language: @current_language_name
)
}
>
<.icon name="hero-trash" class="w-4 h-4" />
{gettext("Clear translation")}
</button>
<% end %>
<% end %>
<%!-- Publication date: only editable by primary language --%>
<%= if @is_primary_language do %>
<div>
<label class="label">
<span class="label-text text-sm font-semibold text-base-content">
{gettext("Publication Date & Time (UTC)")}
</span>
</label>
<input
type="datetime-local"
name="published_at"
value={datetime_local_value(@form["published_at"])}
class={"input input-bordered w-full #{if edit_disabled? or @viewing_older_version, do: "input-disabled bg-base-200"}"}
readonly={edit_disabled? or @viewing_older_version}
/>
<p class="text-xs text-base-content/60 mt-1">
{gettext(
"Updating the publication time changes the post URL to match the new date."
)}
</p>
</div>
<% end %>
</div>
</div>
</div>
</div>
</.form>
</div>
</div>
<%!-- New Version Modal --%>
<%= if @show_new_version_modal do %>
<div class="modal modal-open">
<div class="modal-box max-w-md max-h-[80vh] flex flex-col">
<h3 class="font-bold text-lg mb-4">{gettext("Create New Version")}</h3>
<p class="text-sm text-base-content/70 mb-4">
{gettext("Choose how to create the new version:")}
</p>
<div class="space-y-2 overflow-y-auto flex-1 pr-1">
<%!-- Blank option --%>
<label class="flex items-center gap-3 p-3 rounded-lg border border-base-300 hover:bg-base-200 cursor-pointer">
<input
type="radio"
name="version_source"
class="radio radio-primary"
checked={@new_version_source == nil}
phx-click="set_new_version_source"
phx-value-source="blank"
/>
<div class="flex-1">
<div class="font-medium">{gettext("Start blank")}</div>
<div class="text-xs text-base-content/60">
{gettext("Create an empty version with no content")}
</div>
</div>
</label>
<%!-- Existing versions --%>
<%= for version <- Enum.sort(@available_versions, :desc) do %>
<% status = Map.get(@version_statuses, version, "draft") %>
<label class="flex items-center gap-3 p-3 rounded-lg border border-base-300 hover:bg-base-200 cursor-pointer">
<input
type="radio"
name="version_source"
class="radio radio-primary"
checked={@new_version_source == version}
phx-click="set_new_version_source"
phx-value-source={version}
/>
<div class="flex-1">
<div class="flex items-center gap-1.5">
<span class="font-medium">
{gettext("Copy from v%{version}", version: version)}
</span>
<span class={[
"badge badge-xs h-auto",
status == "published" && "badge-success",
status == "draft" && "badge-warning",
status == "archived" && "badge-ghost"
]}>
{status}
</span>
</div>
<div class="text-xs text-base-content/60">
{gettext("Duplicate all content and translations from version %{version}",
version: version
)}
</div>
</div>
</label>
<% end %>
</div>
<div class="modal-action">
<button
type="button"
class="btn btn-ghost"
phx-click="close_new_version_modal"
>
{gettext("Cancel")}
</button>
<button
type="button"
class="btn btn-primary"
phx-click="create_version_from_source"
>
<.icon name="hero-plus" class="w-4 h-4" />
{gettext("Create Version")}
</button>
</div>
</div>
<div class="modal-backdrop bg-base-content/50" phx-click="close_new_version_modal"></div>
</div>
<% end %>
<%!-- Translation Confirmation Modal --%>
<.confirm_modal
show={@show_translation_confirm}
on_confirm="confirm_translation"
on_cancel="cancel_translation"
title={gettext("Confirm Translation")}
title_icon="hero-language"
messages={@translation_warnings}
prompt={gettext("Do you want to continue with the translation?")}
confirm_text={gettext("Translate")}
cancel_text={gettext("Cancel")}
confirm_icon="hero-language"
/>
<%!-- Media Selector Modal --%>
<.live_component
module={PhoenixKitWeb.Live.Components.MediaSelectorModal}
id="media-selector-modal"
show={@show_media_selector}
mode={@media_selection_mode}
selected_uuids={@media_selected_uuids}
phoenix_kit_current_user={assigns[:phoenix_kit_current_user]}
/>
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>